├── src ├── core │ ├── __init__.py │ ├── domain.py │ └── context.py ├── constants.py ├── events │ ├── base.py │ └── lifecycle.py ├── utils │ ├── logging.py │ └── secrets.py ├── charm.py └── lib │ └── azure_service_principal.py ├── tests ├── integration │ ├── test-charm-azure-service-principal │ │ ├── requirements.txt │ │ ├── actions.yaml │ │ ├── charmcraft.yaml │ │ ├── metadata.yaml │ │ └── src │ │ │ ├── charm.py │ │ │ └── lib │ │ │ └── azure_service_principal.py │ ├── helpers.py │ ├── conftest.py │ └── test_charm.py └── unit │ └── test_charm.py ├── .gitignore ├── renovate.json ├── metadata.yaml ├── config.yaml ├── .github ├── .jira_sync_config.yaml ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── release.yaml │ └── ci.yaml ├── pyproject.toml ├── tox.ini ├── README.md ├── charmcraft.yaml └── LICENSE /src/core/__init__.py: -------------------------------------------------------------------------------- 1 | """Package that contains core code.""" 2 | -------------------------------------------------------------------------------- /tests/integration/test-charm-azure-service-principal/requirements.txt: -------------------------------------------------------------------------------- 1 | ops > 2 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | build/ 3 | *.charm 4 | .tox/ 5 | .coverage 6 | coverage.xml 7 | __pycache__/ 8 | *.py[cod] 9 | .idea 10 | .vscode/ 11 | env/ 12 | .env 13 | .terraform* 14 | *.tfstate* 15 | .ruff_cache/ -------------------------------------------------------------------------------- /tests/integration/test-charm-azure-service-principal/actions.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | get-azure-service-principal-info: 5 | description: Returns Azure service principal credentials 6 | -------------------------------------------------------------------------------- /tests/integration/test-charm-azure-service-principal/charmcraft.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | type: charm 5 | platforms: 6 | ubuntu@24.04:amd64: 7 | parts: 8 | charm: 9 | plugin: charm 10 | -------------------------------------------------------------------------------- /src/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | """Constants to be used in the charm code.""" 5 | 6 | AZURE_SERVICE_PRINCIPAL_RELATION_NAME = "azure-service-principal-credentials" 7 | 8 | AZURE_SERVICE_PRINCIPAL_MANDATORY_OPTIONS = [ 9 | "subscription-id", 10 | "tenant-id", 11 | "credentials", 12 | ] 13 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>canonical/data-platform//renovate_presets/charm.json5" 5 | ], 6 | "reviewers": [ 7 | "theoctober19th", 8 | "mvlassis" 9 | ], 10 | "schedule": ["* 3 * * 5"], 11 | "lockFileMaintenance": { 12 | "enabled": true, 13 | "schedule": ["* 3 * * 5"] 14 | }, 15 | "ignoreDeps": ["ubuntu"] 16 | } 17 | -------------------------------------------------------------------------------- /metadata.yaml: -------------------------------------------------------------------------------- 1 | name: azure-auth-integrator 2 | 3 | summary: Integrator charm for providing access to Azure service principal credentials in other charms. 4 | 5 | description: | 6 | azure-auth-integrator is an integrator charm responsible for relaying the credentials required to interact with Microsoft Entra ID using service principals. 7 | docs: https://discourse.charmhub.io/t/18854 8 | 9 | provides: 10 | azure-service-principal-credentials: 11 | interface: azure_service_principal 12 | -------------------------------------------------------------------------------- /tests/integration/test-charm-azure-service-principal/metadata.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | name: test-charm-azure-service-principal 5 | description: | 6 | Data platform libs application charm used in integration tests. 7 | summary: | 8 | Data platform libs application meant to be used 9 | only for testing of the libs in this repository. 10 | 11 | requires: 12 | azure-service-principal-credentials: 13 | interface: azure_service_principal 14 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | options: 2 | subscription-id: 3 | type: string 4 | description: | 5 | The subscription ID of the service principal used to authenticate with Azure Storage. 6 | tenant-id: 7 | type: string 8 | description: | 9 | The tenant ID of the service principal used to authenticate with Azure Storage. 10 | credentials: 11 | type: secret 12 | description: | 13 | The credentials to connect to Azure service principal. This needs to be a Juju 14 | Secret URI pointing to a secret that contains the following keys: 15 | 1. client-id: ID corresponding to the client that will be used. 16 | 2. client-secret: The secret key corresponding to the client that will be used. 17 | -------------------------------------------------------------------------------- /src/core/domain.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | """Definition of model classes.""" 5 | 6 | from dataclasses import dataclass 7 | 8 | 9 | @dataclass 10 | class AzureServicePrincipalInfo: 11 | """Azure service principal parameters.""" 12 | 13 | subscription_id: str 14 | tenant_id: str 15 | client_id: str 16 | client_secret: str 17 | 18 | def to_dict(self) -> dict: 19 | """Return the Azure service principal parameters as a dictionary.""" 20 | data = { 21 | "subscription-id": self.subscription_id, 22 | "tenant-id": self.tenant_id, 23 | "client-id": self.client_id, 24 | "client-secret": self.client_secret, 25 | } 26 | return data 27 | -------------------------------------------------------------------------------- /.github/.jira_sync_config.yaml: -------------------------------------------------------------------------------- 1 | # Sync GitHub issues to Jira issues 2 | 3 | # Configuration syntax: 4 | # https://github.com/canonical/gh-jira-sync-bot/blob/main/README.md#client-side-configuration 5 | settings: 6 | # Repository specific settings 7 | 8 | # Settings shared across Data Platform repositories 9 | label_mapping: 10 | # If the GitHub issue does not have a label in this mapping, the Jira issue will be created as a Bug 11 | enhancement: Story 12 | jira_project_key: DPE # https://warthogs.atlassian.net/browse/DPE 13 | status_mapping: 14 | opened: untriaged 15 | closed: done # GitHub issue closed as completed 16 | not_planned: rejected # GitHub issue closed as not planned 17 | add_gh_comment: true 18 | sync_description: false 19 | sync_comments: false 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: File a bug report 4 | labels: bug 5 | 6 | --- 7 | 8 | 9 | 10 | ## Steps to reproduce 11 | 12 | 1. 13 | 14 | ## Expected behavior 15 | 16 | 17 | ## Actual behavior 18 | 19 | 20 | 21 | ## Versions 22 | 23 | 24 | Operating system: 25 | 26 | 27 | Juju CLI: 28 | 29 | 30 | Juju agent: 31 | 32 | 33 | Charm revision: 34 | 35 | 36 | microk8s: 37 | 38 | ## Log output 39 | 40 | 41 | Juju debug log: 42 | 43 | 44 | 45 | 46 | ## Additional context 47 | 48 | -------------------------------------------------------------------------------- /tests/integration/helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | """Helpers for running the integration tests.""" 5 | 6 | import json 7 | 8 | import jubilant 9 | 10 | 11 | def get_application_data(juju: jubilant.Juju, app_name: str, relation_name: str) -> dict: 12 | """Retrieves the application data from a specific relation. 13 | 14 | Args: 15 | juju: The Juju client object used to execute CLI commands. 16 | app_name: The name of the Juju application. 17 | relation_name: The name of the relation endpoint to query. 18 | 19 | Returns: 20 | A dictionary containing the application data for the specified relation. 21 | 22 | Raises: 23 | ValueError: If no relation data can be found for the specified 24 | relation endpoint. 25 | """ 26 | unit_name = f"{app_name}/0" 27 | command_stdout = juju.cli("show-unit", unit_name, "--format=json") 28 | result = json.loads(command_stdout) 29 | 30 | relation_data = [ 31 | v for v in result[unit_name]["relation-info"] if v["endpoint"] == relation_name 32 | ] 33 | 34 | if len(relation_data) == 0: 35 | raise ValueError( 36 | f"No relation data could be grabbed on relation with endpoint {relation_name}" 37 | ) 38 | 39 | return relation_data[0]["application-data"] 40 | -------------------------------------------------------------------------------- /src/core/context.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | """Charm context definition and parsing logic.""" 5 | 6 | from ops import ConfigData, Model 7 | 8 | from constants import AZURE_SERVICE_PRINCIPAL_MANDATORY_OPTIONS 9 | from core.domain import AzureServicePrincipalInfo 10 | from utils.logging import WithLogging 11 | from utils.secrets import decode_secret_key 12 | 13 | 14 | class Context(WithLogging): 15 | """Properties and relations of the charm.""" 16 | 17 | def __init__(self, model: Model, config: ConfigData): 18 | self.model = model 19 | self.charm_config = config 20 | 21 | @property 22 | def azure_service_principal(self) -> AzureServicePrincipalInfo | None: 23 | """Return information related to the Azure service principal parameters.""" 24 | for option in AZURE_SERVICE_PRINCIPAL_MANDATORY_OPTIONS: 25 | if self.charm_config.get(option) is None: 26 | return None 27 | 28 | credentials = self.charm_config.get("credentials") 29 | try: 30 | secret_dict = decode_secret_key(self.model, credentials) 31 | except Exception as e: 32 | self.logger.warning(str(e)) 33 | return None 34 | 35 | return AzureServicePrincipalInfo( 36 | subscription_id=self.charm_config.get("subscription-id"), 37 | tenant_id=self.charm_config.get("tenant-id"), 38 | client_id=secret_dict.get("client-id"), 39 | client_secret=secret_dict.get("client-secret"), 40 | ) 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | name: Release to Charmhub 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lib-check: 13 | name: Check libraries 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 5 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v5 19 | with: 20 | fetch-depth: 0 21 | - run: | 22 | # Workaround for https://github.com/canonical/charmcraft/issues/1389#issuecomment-1880921728 23 | touch requirements.txt 24 | - name: Check libs 25 | uses: canonical/charming-actions/check-libraries@2.7.0 26 | with: 27 | # FIXME: CHARMHUB_TOKEN will expire in 2026-09-09 28 | credentials: "${{ secrets.CHARMHUB_TOKEN }}" 29 | github-token: "${{ secrets.GITHUB_TOKEN }}" 30 | ci-tests: 31 | needs: 32 | - lib-check 33 | name: Tests 34 | uses: ./.github/workflows/ci.yaml 35 | secrets: inherit 36 | 37 | release: 38 | name: Release charm 39 | strategy: 40 | matrix: 41 | charm: 42 | - path: . 43 | track: 1 44 | needs: 45 | - lib-check 46 | - ci-tests 47 | uses: canonical/data-platform-workflows/.github/workflows/release_charm_edge.yaml@v35.0.2 48 | with: 49 | track: ${{ matrix.charm.track }} 50 | artifact-prefix: ${{ needs.ci-tests.outputs.artifact-prefix }} 51 | path-to-charm-directory: ${{ matrix.charm.path }} 52 | secrets: 53 | charmhub-token: ${{ secrets.CHARMHUB_TOKEN }} 54 | permissions: 55 | contents: write # Needed to create git tags 56 | -------------------------------------------------------------------------------- /src/events/base.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | """Base utilities exposing common functionalities for all Events classes.""" 5 | 6 | from ops import Model, Object, StatusBase 7 | from ops.model import ActiveStatus, BlockedStatus, ModelError 8 | from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed 9 | 10 | from constants import AZURE_SERVICE_PRINCIPAL_MANDATORY_OPTIONS 11 | from utils.logging import WithLogging 12 | from utils.secrets import decode_secret_key 13 | 14 | 15 | @retry( 16 | stop=stop_after_attempt(3), 17 | wait=wait_fixed(5), 18 | retry=retry_if_exception_type(ModelError), 19 | reraise=True, 20 | ) 21 | def decode_secret_key_with_retry(model: Model, secret_id: str): 22 | """Try to decode the secret key, retry for 3 times before failing.""" 23 | return decode_secret_key(model, secret_id) 24 | 25 | 26 | class BaseEventHandler(Object, WithLogging): 27 | """Base class for all Event Handler classes.""" 28 | 29 | def get_app_status(self, model, charm_config) -> StatusBase: 30 | """Return the status of the charm.""" 31 | missing_options = [] 32 | for config_option in AZURE_SERVICE_PRINCIPAL_MANDATORY_OPTIONS: 33 | if not charm_config.get(config_option): 34 | missing_options.append(config_option) 35 | if missing_options: 36 | self.logger.warning(f"Missing parameters: {missing_options}") 37 | return BlockedStatus(f"Missing parameters: {missing_options}") 38 | try: 39 | decode_secret_key_with_retry(model, charm_config.get("credentials")) 40 | except Exception as e: 41 | self.logger.warning(f"Error in decoding secret: {e}") 42 | return BlockedStatus(str(e)) 43 | 44 | return ActiveStatus() 45 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | [project] 4 | name = "azure-auth-integrator" 5 | requires-python = ">=3.12" 6 | 7 | [tool.poetry] 8 | package-mode = false 9 | requires-poetry = ">=2.0.0" 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.12" 13 | ops = "^3.0.0" 14 | tenacity = ">=9.1.2" 15 | 16 | [tool.poetry.group.charm-libs.dependencies] 17 | cosl = ">=1.0.0" 18 | 19 | [tool.poetry.group.format] 20 | optional = true 21 | 22 | [tool.poetry.group.format.dependencies] 23 | ruff = ">=0.12.3" 24 | 25 | [tool.poetry.group.lint] 26 | optional = true 27 | 28 | [tool.poetry.group.lint.dependencies] 29 | ruff = ">=0.12.3" 30 | codespell = "^2.4.1" 31 | 32 | [tool.poetry.group.unit.dependencies] 33 | coverage = { extras = ["toml"], version = "^7.4.4" } 34 | pytest = "^8.4.1" 35 | ops = { version = "^3.0.0", extras=["testing"] } 36 | 37 | [tool.poetry.group.integration.dependencies] 38 | pytest = "^8.4.1" 39 | pytest-operator = "^0.34.0" 40 | jubilant = "^1.3.0" 41 | 42 | # Testing tools configuration 43 | [tool.coverage.run] 44 | branch = true 45 | 46 | [tool.coverage.report] 47 | show_missing = true 48 | 49 | [tool.pytest.ini_options] 50 | minversion = "8.0" 51 | log_cli_level = "INFO" 52 | asyncio_mode = "auto" 53 | markers = ["unstable"] 54 | 55 | [tool.ruff] 56 | line-length = 99 57 | extend-exclude = ["__pycache__", "*.egg_info"] 58 | src = ["src", "tests"] 59 | 60 | [tool.ruff.lint] 61 | select = ["E", "W", "F", "C", "N", "D", "I001"] 62 | extend-ignore = [ 63 | "D203", 64 | "D204", 65 | "D213", 66 | "D215", 67 | "D400", 68 | "D401", 69 | "D404", 70 | "D406", 71 | "D407", 72 | "D408", 73 | "D409", 74 | "D413", 75 | ] 76 | ignore = ["E501", "D107"] 77 | per-file-ignores = { "tests/*" = ["D100", "D101", "D102", "D103", "D104", "E999"] } 78 | mccabe.max-complexity = 10 79 | 80 | [tool.pyright] 81 | include = ["src"] 82 | extraPaths = ["./lib", "src"] 83 | pythonVersion = "3.12" 84 | pythonPlatform = "All" -------------------------------------------------------------------------------- /src/utils/logging.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | """Utilities for logging.""" 5 | 6 | import os 7 | from logging import Logger, getLogger 8 | from typing import Any, Callable, Literal, TypedDict, Union 9 | 10 | PathLike = Union[str, "os.PathLike[str]"] 11 | 12 | LevelTypes = Literal[ 13 | "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET", 50, 40, 30, 20, 10, 0 14 | ] 15 | StrLevelTypes = Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET"] 16 | 17 | 18 | class LevelsDict(TypedDict): 19 | """Log Levels.""" 20 | 21 | CRITICAL: Literal[50] 22 | ERROR: Literal[40] 23 | WARNING: Literal[30] 24 | INFO: Literal[20] 25 | DEBUG: Literal[10] 26 | NOTSET: Literal[0] 27 | 28 | 29 | DEFAULT_LOG_LEVEL: StrLevelTypes = "INFO" 30 | 31 | levels: LevelsDict = { 32 | "CRITICAL": 50, 33 | "ERROR": 40, 34 | "WARNING": 30, 35 | "INFO": 20, 36 | "DEBUG": 10, 37 | "NOTSET": 0, 38 | } 39 | 40 | 41 | class WithLogging: 42 | """Base class to be used for providing a logger embedded in the class.""" 43 | 44 | @property 45 | def logger(self) -> Logger: 46 | """Create logger. 47 | 48 | :return: default logger. 49 | """ 50 | name_logger = str(self.__class__).replace("", "") 51 | return getLogger(name_logger) 52 | 53 | def log_result( 54 | self, msg: Union[Callable[..., str], str], level: StrLevelTypes = DEFAULT_LOG_LEVEL 55 | ) -> Callable[..., Any]: 56 | """Return a decorator to allow logging of inputs/outputs. 57 | 58 | :param msg: message to log 59 | :param level: logging level 60 | :return: wrapped method. 61 | """ 62 | 63 | def wrap(x: Any) -> Any: 64 | if isinstance(msg, str): 65 | self.logger.log(levels[level], msg) 66 | else: 67 | self.logger.log(levels[level], msg(x)) 68 | return x 69 | 70 | return wrap 71 | -------------------------------------------------------------------------------- /src/utils/secrets.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | """Utility functions related to secrets.""" 5 | 6 | import logging 7 | 8 | import ops 9 | import ops.charm 10 | import ops.framework 11 | import ops.lib 12 | import ops.main 13 | import ops.model 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def decode_secret_key(model: ops.Model, secret_id: str) -> dict[str, str] | None: 19 | """Decode the secret with a given secret_id and return "client-id" and "client-secret". 20 | 21 | Args: 22 | model: juju model to operate in 23 | secret_id: The ID (URI) of the secret that contains the secret key 24 | 25 | Raises: 26 | ops.model.SecretNotFoundError: When either the secret does not exist or the secret 27 | does not have "client-secret" or "client-secret" in its content. 28 | ops.model.ModelError: When the permission to access the secret has not been granted 29 | yet. 30 | 31 | Returns: 32 | A dictionary containing the 'client-id' and 'client-secret'. 33 | """ 34 | try: 35 | secret_content = model.get_secret(id=secret_id).get_content(refresh=True) 36 | 37 | for key in ["client-id", "client-secret"]: 38 | if not secret_content.get(key): 39 | raise ValueError(f"The key '{key}' was not found in secret '{secret_id}'.") 40 | 41 | return { 42 | "client-id": secret_content["client-id"], 43 | "client-secret": secret_content["client-secret"], 44 | } 45 | except ops.model.SecretNotFoundError: 46 | raise ops.model.SecretNotFoundError(f"The secret '{secret_id}' does not exist.") 47 | except ValueError as ve: 48 | raise ops.model.SecretNotFoundError(ve) 49 | except ops.model.ModelError as me: 50 | if "permission denied" in str(me): 51 | raise ops.model.ModelError( 52 | f"Permission for secret '{secret_id}' has not been granted." 53 | ) 54 | raise 55 | -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | """Fixtures used across the integration tests.""" 5 | 6 | import logging 7 | from pathlib import Path 8 | 9 | import jubilant 10 | import pytest 11 | 12 | WAIT_TIMEOUT = 10 * 60 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | @pytest.fixture(scope="module") 18 | def juju(request: pytest.FixtureRequest): 19 | keep_models = bool(request.config.getoption("--keep-models")) 20 | model_name = request.config.getoption("--model") 21 | 22 | if model_name: 23 | juju_instance = jubilant.Juju(model=model_name) 24 | juju_instance.wait_timeout = WAIT_TIMEOUT 25 | juju_instance.model_config({"update-status-hook-interval": "60s"}) 26 | 27 | yield juju_instance 28 | 29 | if request.session.testsfailed: 30 | log = juju_instance.debug_log(limit=30) 31 | print(log, end="") 32 | 33 | else: 34 | with jubilant.temp_model(keep=keep_models) as juju_instance: 35 | juju_instance.wait_timeout = WAIT_TIMEOUT 36 | juju_instance.model_config({"update-status-hook-interval": "60s"}) 37 | 38 | yield juju_instance # run the test 39 | 40 | if request.session.testsfailed: 41 | log = juju_instance.debug_log(limit=30) 42 | print(log, end="") 43 | 44 | 45 | @pytest.fixture 46 | def azure_auth_charm_path() -> Path: 47 | if not (path := next(iter(Path.cwd().glob("*.charm")), None)): 48 | raise FileNotFoundError("Could not find packed azure-auth-integrator charm.") 49 | 50 | return path 51 | 52 | 53 | @pytest.fixture 54 | def test_charm_path() -> Path: 55 | if not ( 56 | path := next( 57 | iter( 58 | (Path.cwd() / "tests/integration/test-charm-azure-service-principal").glob( 59 | "*.charm" 60 | ) 61 | ), 62 | None, 63 | ) 64 | ): 65 | raise FileNotFoundError("Could not find packed test charm.") 66 | 67 | return path 68 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | [tox] 5 | no_package = True 6 | skip_missing_interpreters = True 7 | env_list = lint, unit 8 | 9 | [vars] 10 | src_path = {tox_root}/src 11 | tests_path = {tox_root}/tests 12 | all_path = {[vars]src_path} {[vars]tests_path} 13 | 14 | [testenv] 15 | set_env = 16 | PYTHONPATH = {tox_root}/lib:{[vars]src_path} 17 | PYTHONBREAKPOINT=ipdb.set_trace 18 | PY_COLORS=1 19 | pass_env = 20 | PYTHONPATH 21 | CHARM_BUILD_DIR 22 | MODEL_SETTINGS 23 | allowlist_externals = 24 | poetry 25 | 26 | [testenv:format] 27 | description = Apply coding style standards to code 28 | commands_pre = 29 | poetry install --only format 30 | commands = 31 | poetry install --only format 32 | poetry run ruff format {[vars]all_path} 33 | 34 | [testenv:lint] 35 | description = Check code against coding style standards 36 | commands_pre = 37 | poetry install --only lint 38 | commands = 39 | poetry run ruff format --check {[vars]all_path} 40 | poetry run ruff check --fix --exit-non-zero-on-fix --exclude lib {[vars]all_path} 41 | poetry run codespell {tox_root} \ 42 | --skip {tox_root}/.git \ 43 | --skip {tox_root}/.tox \ 44 | --skip {tox_root}/build \ 45 | --skip {tox_root}/lib \ 46 | --skip {tox_root}/venv \ 47 | --skip {tox_root}/.mypy_cache \ 48 | --skip {tox_root}/icon.svg \ 49 | --skip {tox_root}/poetry.lock 50 | 51 | 52 | [testenv:unit] 53 | description = Run unit tests 54 | commands_pre = 55 | poetry install --only main,charm-libs,unit 56 | commands = 57 | poetry run coverage run --source={[vars]src_path} \ 58 | -m pytest -v --tb native -s --log-cli-level=DEBUG {posargs} {[vars]tests_path}/unit 59 | poetry run coverage report 60 | 61 | [testenv:integration] 62 | description = Run integration tests 63 | set_env = 64 | {[testenv]set_env} 65 | pass_env = 66 | CI 67 | GITHUB_OUTPUT 68 | SECRETS_FROM_GITHUB 69 | commands_pre = 70 | poetry install --only integration 71 | commands = 72 | poetry run pytest -v --tb native --log-cli-level=INFO -s --ignore={[vars]tests_path}/unit/ {posargs} 73 | -------------------------------------------------------------------------------- /src/charm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Copyright 2025 Canonical Ltd. 4 | # See LICENSE file for licensing details. 5 | 6 | """A charm for integrating Azure service principal credentials to a charmed application.""" 7 | 8 | import logging 9 | 10 | import ops 11 | 12 | from core.context import Context 13 | from events.lifecycle import LifecycleEvents 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class AzureAuthIntegratorCharm(ops.charm.CharmBase): 19 | """The main class for the charm.""" 20 | 21 | def __init__(self, *args) -> None: 22 | super().__init__(*args) 23 | 24 | # Context 25 | self.context = Context(model=self.model, config=self.config) 26 | 27 | # Event Handlers 28 | self.lifecycle_events = LifecycleEvents(self, self.context) 29 | 30 | self.framework.observe(self.on.collect_unit_status, self._on_collect_unit_status) 31 | self.framework.observe(self.on.collect_app_status, self._on_collect_app_status) 32 | 33 | def _on_collect_unit_status(self, event: ops.CollectStatusEvent) -> None: 34 | """Set the status of the unit. 35 | 36 | This must be the only place in the codebase where we set the unit status. 37 | 38 | The priority order is as follows: 39 | - domain logic 40 | - plain active status 41 | """ 42 | for status in self._collect_domain_statuses(): 43 | event.add_status(status) 44 | 45 | event.add_status(ops.model.ActiveStatus()) 46 | 47 | def _on_collect_app_status(self, event: ops.CollectStatusEvent) -> None: 48 | """Set the status of the app. 49 | 50 | This must be the only place in the codebase where we set the app status. 51 | """ 52 | for status in self._collect_domain_statuses(): 53 | event.add_status(status) 54 | 55 | event.add_status(ops.model.ActiveStatus()) 56 | 57 | def _collect_domain_statuses(self) -> list[ops.StatusBase]: 58 | """Return a list of each component status of the charm.""" 59 | statuses: list[ops.StatusBase] = [] 60 | 61 | statuses.append( 62 | self.lifecycle_events.get_app_status( 63 | self.lifecycle_events.charm.model, 64 | self.lifecycle_events.charm.config, 65 | ) 66 | ) 67 | 68 | return statuses 69 | 70 | 71 | if __name__ == "__main__": 72 | ops.main(AzureAuthIntegratorCharm) 73 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | name: Tests 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: true 9 | 10 | on: 11 | pull_request: 12 | schedule: 13 | - cron: "57 0 * * *" # Daily at 00:57 UTC 14 | # Triggered on push to branch "main" by .github/workflows/release.yaml 15 | workflow_call: 16 | outputs: 17 | artifact-prefix: 18 | description: build_charm.yaml `artifact-prefix` output 19 | value: ${{ jobs.build.outputs.artifact-prefix }} 20 | 21 | jobs: 22 | lint: 23 | name: Lint 24 | runs-on: ubuntu-latest 25 | timeout-minutes: 5 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v5 29 | - name: Install tox & poetry 30 | run: | 31 | pipx install tox 32 | pipx install poetry 33 | - name: Run linters 34 | run: | 35 | tox run -e lint 36 | 37 | unit-test: 38 | name: Unit tests 39 | runs-on: ubuntu-latest 40 | timeout-minutes: 5 41 | steps: 42 | - name: Checkout 43 | uses: actions/checkout@v5 44 | - name: Install tox & poetry 45 | run: | 46 | pipx install tox 47 | pipx install poetry 48 | - name: Run tests 49 | run: | 50 | tox run -vve unit 51 | 52 | build: 53 | strategy: 54 | matrix: 55 | path: 56 | - . 57 | - tests/integration/test-charm-azure-service-principal 58 | name: Build charm | ${{ matrix.path }} 59 | uses: canonical/data-platform-workflows/.github/workflows/build_charm.yaml@v29.0.0 60 | with: 61 | path-to-charm-directory: ${{ matrix.path }} 62 | cache: false 63 | 64 | integration-test: 65 | name: Run charm integration tests 66 | needs: 67 | - lint 68 | - build 69 | runs-on: ubuntu-24.04 70 | steps: 71 | - name: Checkout 72 | uses: actions/checkout@v5 73 | - name: Setup operator environment 74 | uses: charmed-kubernetes/actions-operator@main 75 | with: 76 | provider: microk8s 77 | channel: 1.32-strict/stable 78 | juju-channel: 3.6/stable 79 | - name: Download packed charm(s) 80 | uses: actions/download-artifact@v5 81 | with: 82 | artifact-prefix: ${{ needs.build.outputs.artifact-prefix }} 83 | merge-multiple: true 84 | - name: Install tox & poetry 85 | run: | 86 | pipx install tox 87 | pipx install poetry 88 | - name: Run integration tests 89 | run: tox -e integration 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Azure auth integrator 2 | [![Charmhub](https://charmhub.io/azure-auth-integrator/badge.svg)](https://charmhub.io/azure-auth-integrator) 3 | [![Release](https://github.com/canonical/azure-auth-integrator/actions/workflows/release.yaml/badge.svg)](https://github.com/canonical/azure-auth-integrator/actions/workflows/release.yaml) 4 | [![Tests](https://github.com/canonical/azure-auth-integrator/actions/workflows/ci.yaml/badge.svg)](https://github.com/canonical/azure-auth-integrator/actions/workflows/ci.yaml) 5 | 6 | ## Description 7 | 8 | `azure-auth-integrator` is an integrator charm responsible for relaying the credentials required to interact with Microsoft [Entra ID](https://learn.microsoft.com/en-us/entra/fundamentals/what-is-entra) using [Service principals](https://learn.microsoft.com/en-us/entra/identity-platform/app-objects-and-service-principals?tabs=browser). 9 | 10 | ## Get started 11 | 12 | Deploy `azure-auth-integrator` by running: 13 | ```shell 14 | juju deploy azure-auth-integrator --channel 1/edge 15 | ``` 16 | 17 | Now configure it with your Azure credentials: 18 | ```shell 19 | juju config azure-auth-integrator subscription-id= tenant-id= 20 | ``` 21 | 22 | Requirer charms also need the `client-id` and `client-secret` which uniquely identify your app. To pass this sensitive information to the charm, add a Juju secret with these values, and grant access to `azure-auth-integrator` as follows: 23 | ```shell 24 | juju add-secret my-secret client-id= client-secret= 25 | juju grant-secret my-secret azure-auth-integrator 26 | ``` 27 | 28 | Use the URI of the added secret in the previous step as the value for the `credentials` configuration option: 29 | ``` 30 | juju config azure-auth-integrator credentials= 31 | ``` 32 | 33 | After deploying the requirer charm, integrate it with `azure-auth-integrator` by running: 34 | ```shell 35 | juju integrate azure-auth-integrator 36 | ``` 37 | 38 | The requirer charm should now have access to all credentials needed to access your Azure resources. 39 | 40 | ## Community and support 41 | 42 | `azure-auth-integrator` is an open-source project that welcomes community contributions, suggestions, 43 | fixes and constructive feedback. 44 | 45 | - Report [issues](https://github.com/canonical/azure-auth-integrator/issues). 46 | - [Contact us on Matrix](https://matrix.to/#/#charmhub-data-platform:ubuntu.com). 47 | - Explore [Canonical Data & AI solutions](https://canonical.com/data). 48 | 49 | ## License and copyright 50 | 51 | `azure-auth-integrator` is free software, distributed under the Apache Software License, version 2.0. See [LICENSE](https://www.apache.org/licenses/LICENSE-2.0) for more information. 52 | -------------------------------------------------------------------------------- /tests/integration/test-charm-azure-service-principal/src/charm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright 2025 Canonical Ltd. 3 | # See LICENSE file for licensing details. 4 | 5 | """Application charm that connects to the azure auth integrator provider charm. 6 | 7 | This charm is meant to be used only for testing 8 | the azure service principal requires-provides relation. 9 | """ 10 | 11 | import logging 12 | 13 | from ops.charm import ActionEvent, CharmBase, RelationJoinedEvent 14 | from ops.main import main 15 | from ops.model import ActiveStatus, BlockedStatus 16 | 17 | from lib.azure_service_principal import ( 18 | AzureServicePrincipalRequirer, 19 | ServicePrincipalInfoChangedEvent, 20 | ServicePrincipalInfoGoneEvent, 21 | ) 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | RELATION_NAME = "azure-service-principal-credentials" 26 | CONTAINER_NAME = "test-bucket" 27 | 28 | 29 | class ApplicationCharm(CharmBase): 30 | """Application charm that relates to Azure auth integrator.""" 31 | 32 | def __init__(self, *args): 33 | super().__init__(*args) 34 | 35 | # Default charm events. 36 | self.framework.observe(self.on.start, self._on_start) 37 | 38 | self.azure_service_principal_client = AzureServicePrincipalRequirer(self, RELATION_NAME) 39 | 40 | self.framework.observe( 41 | self.azure_service_principal_client.on.service_principal_info_changed, 42 | self._on_service_principal_info_changed, 43 | ) 44 | self.framework.observe(self.on[RELATION_NAME].relation_joined, self._on_relation_joined) 45 | self.framework.observe( 46 | self.azure_service_principal_client.on.service_principal_info_gone, 47 | self._on_service_principal_info_gone, 48 | ) 49 | self.framework.observe(self.on.update_status, self._on_update_status) 50 | self.framework.observe( 51 | self.on.get_azure_service_principal_info_action, self._on_get_service_principal_info 52 | ) 53 | 54 | def _on_start(self, _) -> None: 55 | """Only sets an waiting status.""" 56 | self.unit.status = BlockedStatus("Waiting for relation.") 57 | 58 | def _on_relation_joined(self, _: RelationJoinedEvent): 59 | """On Azure credential relation joined.""" 60 | logger.info("azure-service-principal-credentials relation joined...") 61 | self.unit.status = ActiveStatus() 62 | 63 | def _on_service_principal_info_changed(self, e: ServicePrincipalInfoChangedEvent): 64 | service_principal_info = ( 65 | self.azure_service_principal_client.get_azure_service_principal_info() 66 | ) 67 | if service_principal_info: 68 | logger.debug(f"Credentials changed. New credentials: {service_principal_info}") 69 | 70 | def _on_service_principal_info_gone(self, _: ServicePrincipalInfoGoneEvent): 71 | logger.debug("Credentials gone...") 72 | self.unit.status = BlockedStatus("Waiting for relation.") 73 | 74 | def _on_update_status(self, _): 75 | service_principal_info = ( 76 | self.azure_service_principal_client.get_azure_service_principal_info() 77 | ) 78 | if service_principal_info: 79 | logger.debug(f"Azure service principal client info: {service_principal_info}") 80 | 81 | def _on_get_service_principal_info(self, event: ActionEvent): 82 | service_principal_info = ( 83 | self.azure_service_principal_client.get_azure_service_principal_info() 84 | ) 85 | if service_principal_info: 86 | event.set_results(service_principal_info) 87 | logger.debug(f"Azure service principal client info: {service_principal_info}") 88 | 89 | 90 | if __name__ == "__main__": 91 | main(ApplicationCharm) 92 | -------------------------------------------------------------------------------- /src/events/lifecycle.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | """Azure Service Principal provider related event handlers.""" 5 | 6 | import ops 7 | from ops import CharmBase 8 | from ops.charm import ConfigChangedEvent 9 | 10 | from constants import AZURE_SERVICE_PRINCIPAL_RELATION_NAME 11 | from core.context import Context 12 | from events.base import BaseEventHandler 13 | from lib.azure_service_principal import ( 14 | AzureServicePrincipalProviderData, 15 | AzureServicePrincipalProviderEventHandlers, 16 | ServicePrincipalInfoRequestedEvent, 17 | ) 18 | from utils.logging import WithLogging 19 | 20 | 21 | class LifecycleEvents(BaseEventHandler, WithLogging): 22 | """Class implementing lifecycle charm-related event hooks.""" 23 | 24 | def __init__(self, charm: CharmBase, context: Context): 25 | super().__init__(charm, "lifecycle") 26 | 27 | self.charm = charm 28 | self.context = context 29 | 30 | self.azure_service_principal_provider_data = AzureServicePrincipalProviderData( 31 | self.charm.model, AZURE_SERVICE_PRINCIPAL_RELATION_NAME 32 | ) 33 | self.azure_service_principal_provider = AzureServicePrincipalProviderEventHandlers( 34 | self.charm, self.azure_service_principal_provider_data 35 | ) 36 | 37 | self.framework.observe(self.charm.on.update_status, self._on_update_status) 38 | self.framework.observe(self.charm.on.config_changed, self._on_config_changed) 39 | self.framework.observe(self.charm.on.secret_changed, self._on_secret_changed) 40 | self.framework.observe( 41 | self.azure_service_principal_provider.on.service_principal_info_requested, 42 | self._on_azure_service_principal_info_requested, 43 | ) 44 | 45 | def _on_update_status(self, event: ops.UpdateStatusEvent): 46 | """Handle the update status event.""" 47 | self._update_provider_data() 48 | 49 | def _on_config_changed(self, event: ConfigChangedEvent) -> None: # noqa: C901 50 | """Event handler for configuration changed events.""" 51 | # Only execute in the unit leader 52 | if not self.charm.unit.is_leader(): 53 | return 54 | 55 | self.logger.debug(f"Config changed... Current configuration: {self.charm.config}") 56 | self._update_provider_data() 57 | 58 | def _on_secret_changed(self, event: ops.SecretChangedEvent): 59 | """Handle the secret changed event. 60 | 61 | When a secret is changed, it is first checked that whether this particular secret 62 | is used in the charm's config. If yes, the secret is to be updated in the relation 63 | databag. 64 | """ 65 | # Only execute in the unit leader 66 | if not self.charm.unit.is_leader(): 67 | return 68 | 69 | if not self.charm.config.get("credentials"): 70 | return 71 | 72 | secret = event.secret 73 | if self.charm.config.get("credentials") != secret.id: 74 | return 75 | 76 | self._update_provider_data() 77 | 78 | def _update_provider_data(self): 79 | """Update the contents of the relation data bag.""" 80 | if ( 81 | len(self.azure_service_principal_provider_data.relations) > 0 82 | and self.context.azure_service_principal 83 | ): 84 | for relation in self.azure_service_principal_provider_data.relations: 85 | self.azure_service_principal_provider_data.update_relation_data( 86 | relation.id, self.context.azure_service_principal.to_dict() 87 | ) 88 | 89 | def _on_azure_service_principal_info_requested( 90 | self, event: ServicePrincipalInfoRequestedEvent 91 | ): 92 | """Handle the `service-principal-info-requested` event.""" 93 | self.logger.info("On service-principal-info-requested") 94 | if not self.charm.unit.is_leader(): 95 | return 96 | 97 | self._update_provider_data() 98 | -------------------------------------------------------------------------------- /charmcraft.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | type: charm 5 | 6 | platforms: 7 | ubuntu@24.04:amd64: 8 | # Files implicitly created by charmcraft without a part: 9 | # - dispatch (https://github.com/canonical/charmcraft/pull/1898) 10 | # - manifest.yaml 11 | # (https://github.com/canonical/charmcraft/blob/9ff19c328e23b50cc06f04e8a5ad4835740badf4/charmcraft/services/package.py#L259) 12 | # Files implicitly copied/"staged" by charmcraft without a part: 13 | # - actions.yaml, config.yaml, metadata.yaml 14 | # (https://github.com/canonical/charmcraft/blob/9ff19c328e23b50cc06f04e8a5ad4835740badf4/charmcraft/services/package.py#L290-L293 15 | # https://github.com/canonical/charmcraft/blob/9ff19c328e23b50cc06f04e8a5ad4835740badf4/charmcraft/services/package.py#L156-L157) 16 | parts: 17 | # "poetry-deps" part name is a magic constant 18 | # https://github.com/canonical/craft-parts/pull/901 19 | poetry-deps: 20 | plugin: nil 21 | build-packages: 22 | - curl 23 | override-build: | 24 | # Use environment variable instead of `--break-system-packages` to avoid failing on older 25 | # versions of pip that do not recognize `--break-system-packages` 26 | # `--user` needed (in addition to `--break-system-packages`) for Ubuntu >=24.04 27 | PIP_BREAK_SYSTEM_PACKAGES=true python3 -m pip install --user --upgrade pip==25.1.1 # renovate: charmcraft-pip-latest 28 | 29 | # Use uv to install poetry so that a newer version of Python can be installed if needed by poetry 30 | curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.7.21/uv-installer.sh | sh # renovate: charmcraft-uv-latest 31 | # poetry 2.0.0 requires Python >=3.9 32 | if ! "$HOME/.local/bin/uv" python find '>=3.9' 33 | then 34 | # Use first Python version that is >=3.9 and available in an Ubuntu LTS 35 | # (to reduce the number of Python versions we use) 36 | "$HOME/.local/bin/uv" python install 3.10.12 # renovate: charmcraft-python-ubuntu-22.04 37 | fi 38 | "$HOME/.local/bin/uv" tool install --no-python-downloads --python '>=3.9' poetry==2.1.3 --with poetry-plugin-export==1.9.0 # renovate: charmcraft-poetry-latest 39 | 40 | ln -sf "$HOME/.local/bin/poetry" /usr/local/bin/poetry 41 | # "charm-poetry" part name is arbitrary; use for consistency 42 | # Avoid using "charm" part name since that has special meaning to charmcraft 43 | charm-poetry: 44 | # By default, the `poetry` plugin creates/stages these directories: 45 | # - lib, src 46 | # (https://github.com/canonical/charmcraft/blob/9ff19c328e23b50cc06f04e8a5ad4835740badf4/charmcraft/parts/plugins/_poetry.py#L76-L78) 47 | # - venv 48 | # (https://github.com/canonical/charmcraft/blob/9ff19c328e23b50cc06f04e8a5ad4835740badf4/charmcraft/parts/plugins/_poetry.py#L95 49 | # https://github.com/canonical/craft-parts/blob/afb0d652eb330b6aaad4f40fbd6e5357d358de47/craft_parts/plugins/base.py#L270) 50 | plugin: poetry 51 | source: . 52 | after: 53 | - poetry-deps 54 | poetry-export-extra-args: ['--only', 'main'] 55 | build-packages: 56 | - libffi-dev # Needed to build Python dependencies with Rust from source 57 | - libssl-dev # Needed to build Python dependencies with Rust from source 58 | - pkg-config # Needed to build Python dependencies with Rust from source 59 | override-build: | 60 | # Workaround for https://github.com/canonical/charmcraft/issues/2068 61 | # rustup used to install rustc and cargo, which are needed to build Python dependencies with Rust from source 62 | if [[ "$CRAFT_PLATFORM" == ubuntu@20.04:* || "$CRAFT_PLATFORM" == ubuntu@22.04:* ]] 63 | then 64 | snap install rustup --classic 65 | else 66 | apt-get install rustup -y 67 | fi 68 | 69 | # If Ubuntu version < 24.04, rustup was installed from snap instead of from the Ubuntu 70 | # archive—which means the rustup version could be updated at any time. Print rustup version 71 | # to build log to make changes to the snap's rustup version easier to track 72 | rustup --version 73 | 74 | # rpds-py (Python package) >=0.19.0 requires rustc >=1.76, which is not available in the 75 | # Ubuntu 22.04 archive. Install rustc and cargo using rustup instead of the Ubuntu archive 76 | rustup set profile minimal 77 | rustup default 1.83.0 # renovate: charmcraft-rust-latest 78 | 79 | craftctl default 80 | # Include requirements.txt in *.charm artifact for easier debugging 81 | cp requirements.txt "$CRAFT_PART_INSTALL/requirements.txt" 82 | # "files" part name is arbitrary; use for consistency 83 | files: 84 | plugin: dump 85 | source: . 86 | stage: 87 | - LICENSE 88 | -------------------------------------------------------------------------------- /tests/unit/test_charm.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | """Unit tests for the azure-auth-integrator charm.""" 5 | 6 | import dataclasses 7 | import json 8 | import logging 9 | from pathlib import Path 10 | 11 | import pytest 12 | import yaml 13 | from ops.model import ActiveStatus, BlockedStatus 14 | from ops.testing import Context, Relation, Secret, State 15 | from src.charm import AzureAuthIntegratorCharm 16 | 17 | CONFIG = yaml.safe_load(Path("./config.yaml").read_text()) 18 | METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) 19 | 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | @pytest.fixture() 25 | def ctx() -> Context: 26 | ctx = Context(AzureAuthIntegratorCharm, meta=METADATA, config=CONFIG, unit_id=0) 27 | return ctx 28 | 29 | 30 | @pytest.fixture() 31 | def base_state() -> State: 32 | return State(leader=True) 33 | 34 | 35 | @pytest.fixture() 36 | def charm_configuration() -> dict: 37 | return json.loads(json.dumps(CONFIG)) 38 | 39 | 40 | def test_on_start_blocked(ctx: Context[AzureAuthIntegratorCharm], base_state: State): 41 | """Tests than on start, the status is blocked, waiting for credentials.""" 42 | # Arrange 43 | state_in = base_state 44 | 45 | # Act 46 | state_out = ctx.run(ctx.on.start(), state_in) 47 | 48 | # Assert 49 | assert isinstance(status := state_out.unit_status, BlockedStatus) 50 | assert "credentials" in status.message 51 | 52 | 53 | def test_on_start_no_secret_access_blocked( 54 | ctx: Context[AzureAuthIntegratorCharm], base_state: State, charm_configuration: dict 55 | ): 56 | """Tests that the charm's status is blocked if not granted secret access.""" 57 | # Arrange 58 | charm_configuration["options"]["subscription-id"]["default"] = "subscriptionid" 59 | charm_configuration["options"]["tenant-id"]["default"] = "tenantid" 60 | # This secret does not exist 61 | charm_configuration["options"]["credentials"]["default"] = "secret:1a2b3c4d5e6f7g8h9i0j" 62 | ctx = Context(AzureAuthIntegratorCharm, meta=METADATA, config=charm_configuration, unit_id=0) 63 | state_in = base_state 64 | 65 | # Act 66 | state_out = ctx.run(ctx.on.start(), state_in) 67 | 68 | # Assert 69 | assert isinstance(status := state_out.unit_status, BlockedStatus) 70 | assert "does not exist" in status.message 71 | 72 | 73 | def test_on_start_missing_secret_fields( 74 | ctx: Context[AzureAuthIntegratorCharm], base_state: State, charm_configuration: dict 75 | ): 76 | """Tests that the charm's status is blocked if the secret is missing the required fields.""" 77 | # Arrange 78 | credentials_secret = Secret( 79 | tracked_content={ 80 | "client-id": "clientid", 81 | } 82 | ) 83 | charm_configuration["options"]["subscription-id"]["default"] = "subscriptionid" 84 | charm_configuration["options"]["tenant-id"]["default"] = "tenantid" 85 | charm_configuration["options"]["credentials"]["default"] = credentials_secret.id 86 | ctx = Context(AzureAuthIntegratorCharm, meta=METADATA, config=charm_configuration, unit_id=0) 87 | state_in = dataclasses.replace(base_state, secrets={credentials_secret}) 88 | 89 | # Act 90 | state_out = ctx.run(ctx.on.start(), state_in) 91 | 92 | # Assert 93 | assert isinstance(status := state_out.unit_status, BlockedStatus) 94 | assert "was not found in secret" in status.message 95 | 96 | 97 | def test_on_start_active( 98 | ctx: Context[AzureAuthIntegratorCharm], base_state: State, charm_configuration: dict 99 | ): 100 | """Tests that with all configuration options, the status is active.""" 101 | # Arrange 102 | credentials_secret = Secret( 103 | tracked_content={ 104 | "client-id": "clientid", 105 | "client-secret": "clientsecret", 106 | } 107 | ) 108 | charm_configuration["options"]["subscription-id"]["default"] = "subscriptionid" 109 | charm_configuration["options"]["tenant-id"]["default"] = "tenantid" 110 | charm_configuration["options"]["credentials"]["default"] = credentials_secret.id 111 | ctx = Context(AzureAuthIntegratorCharm, meta=METADATA, config=charm_configuration, unit_id=0) 112 | state_in = dataclasses.replace(base_state, secrets={credentials_secret}) 113 | 114 | # Act 115 | state_out = ctx.run(ctx.on.start(), state_in) 116 | 117 | # Assert 118 | assert state_out.unit_status == ActiveStatus() 119 | 120 | 121 | def test_relation_application_data( 122 | ctx: Context[AzureAuthIntegratorCharm], base_state: State, charm_configuration: dict 123 | ): 124 | """Test that after relating, the charm correctly provides all credentials via the application data.""" 125 | # Arrange 126 | credentials_secret = Secret( 127 | tracked_content={ 128 | "client-id": "clientid", 129 | "client-secret": "clientsecret", 130 | } 131 | ) 132 | charm_configuration["options"]["subscription-id"]["default"] = "subscriptionid" 133 | charm_configuration["options"]["tenant-id"]["default"] = "tenantid" 134 | charm_configuration["options"]["credentials"]["default"] = credentials_secret.id 135 | ctx = Context(AzureAuthIntegratorCharm, meta=METADATA, config=charm_configuration, unit_id=0) 136 | azure_service_principal_relation = Relation(endpoint="azure-service-principal-credentials") 137 | state_in = dataclasses.replace( 138 | base_state, relations=[azure_service_principal_relation], secrets={credentials_secret} 139 | ) 140 | 141 | # Act 142 | state_out = ctx.run(ctx.on.relation_joined(azure_service_principal_relation), state_in) 143 | 144 | # Assert 145 | assert state_out.unit_status == ActiveStatus() 146 | provider_data = state_out.get_relation(azure_service_principal_relation.id).local_app_data 147 | assert provider_data["subscription-id"] == "subscriptionid" 148 | assert provider_data["tenant-id"] == "tenantid" 149 | assert provider_data["client-id"] == "clientid" 150 | assert provider_data["client-secret"] == "clientsecret" 151 | -------------------------------------------------------------------------------- /tests/integration/test_charm.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Canonical Ltd. 2 | # See LICENSE file for licensing details. 3 | 4 | """Integration tests for the azure-auth-integrator charm.""" 5 | 6 | import logging 7 | from pathlib import Path 8 | 9 | import jubilant 10 | import pytest 11 | import yaml 12 | from helpers import get_application_data 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | CHARM_METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) 18 | APP_NAME = CHARM_METADATA["name"] 19 | TEST_CHARM_METADATA = yaml.safe_load( 20 | Path("./tests/integration/test-charm-azure-service-principal/metadata.yaml").read_text() 21 | ) 22 | TEST_APP_NAME = TEST_CHARM_METADATA["name"] 23 | TEST_APP_UNIT_NAME = f"{TEST_APP_NAME}/0" 24 | 25 | RELATION_NAME = "azure-service-principal-credentials" 26 | SECRET_IDENTIFIER = "test-secret" 27 | 28 | SUBSCRIPTION_ID_TEST_VALUE = "subscription-test" 29 | TENANT_ID_TEST_VALUE = "tenant-test" 30 | CLIENT_ID_TEST_VALUE = "client-id-test" 31 | CLIENT_SECRET_TEST_VALUE = "client-secret-test" 32 | SUBSCRIPTION_ID_NEW_VALUE = "subscription-test-new" 33 | CLIENT_SECRET_NEW_VALUE = "client-secret-test-new" 34 | 35 | 36 | @pytest.mark.abort_on_fail 37 | def test_build_and_deploy_charm( 38 | juju: jubilant.Juju, azure_auth_charm_path: Path, test_charm_path: Path 39 | ): 40 | """Tests building and deploying the integrator and the test charm, with proper statuses.""" 41 | juju.deploy( 42 | azure_auth_charm_path, 43 | app=APP_NAME, 44 | ) 45 | 46 | juju.deploy( 47 | test_charm_path, 48 | app=TEST_APP_NAME, 49 | ) 50 | 51 | juju.wait(jubilant.all_blocked, error=jubilant.any_error) 52 | 53 | 54 | @pytest.mark.abort_on_fail 55 | def test_config_options(juju: jubilant.Juju): 56 | """Tests proper handling of configuration parameters.""" 57 | juju.config( 58 | APP_NAME, 59 | {"subscription-id": SUBSCRIPTION_ID_TEST_VALUE, "tenant-id": TENANT_ID_TEST_VALUE}, 60 | ) 61 | 62 | # Status should be blocked due to missing "credentials" 63 | status = juju.wait( 64 | lambda status: jubilant.all_blocked(status, APP_NAME), 65 | ) 66 | assert status.apps[APP_NAME].app_status.message == "Missing parameters: ['credentials']" 67 | 68 | # Assert that configuring a secret that doesn't exist produces an error message 69 | # Create a secret and immediately remove it 70 | secret_uri = juju.add_secret("fake-secret", {"test-key": "test-value"}) 71 | juju.remove_secret(secret_uri) 72 | juju.config(APP_NAME, {"credentials": secret_uri}) 73 | juju.wait(jubilant.all_agents_idle, delay=5.0) 74 | status = juju.wait( 75 | lambda status: jubilant.all_blocked(status, APP_NAME), 76 | ) 77 | assert status.apps[APP_NAME].app_status.message == f"The secret '{secret_uri}' does not exist." 78 | 79 | # Add a secret but don't grant permission, so status should stay blocked 80 | secret_uri = juju.add_secret(SECRET_IDENTIFIER, {"client-id": CLIENT_ID_TEST_VALUE}) 81 | juju.wait(jubilant.all_agents_idle) 82 | juju.config(APP_NAME, {"credentials": secret_uri}) 83 | juju.wait(jubilant.all_agents_idle) 84 | status = juju.wait(lambda status: jubilant.all_blocked(status, APP_NAME)) 85 | assert ( 86 | status.apps[APP_NAME].app_status.message 87 | == f"Permission for secret '{secret_uri}' has not been granted." 88 | ) 89 | juju.remove_secret(secret_uri) 90 | 91 | # Add a secret but don't provide all values for the secret, so status should stay blocked 92 | secret_uri = juju.add_secret(SECRET_IDENTIFIER, {"client-id": CLIENT_ID_TEST_VALUE}) 93 | juju.grant_secret(secret_uri, APP_NAME) 94 | juju.config(APP_NAME, {"credentials": secret_uri}) 95 | juju.wait(jubilant.all_agents_idle, delay=10.0) 96 | status = juju.wait(lambda status: jubilant.all_blocked(status, APP_NAME)) 97 | assert ( 98 | status.apps[APP_NAME].app_status.message 99 | == f"The key 'client-secret' was not found in secret '{secret_uri}'." 100 | ) 101 | 102 | # All credentials have been provided, status should now be active 103 | secret_uri = juju.update_secret( 104 | SECRET_IDENTIFIER, 105 | {"client-id": CLIENT_ID_TEST_VALUE, "client-secret": CLIENT_SECRET_TEST_VALUE}, 106 | ) 107 | juju.wait(jubilant.all_agents_idle, delay=5.0) 108 | status = juju.wait(lambda status: jubilant.all_active(status, APP_NAME)) 109 | 110 | 111 | @pytest.mark.abort_on_fail 112 | def test_relation_creation(juju: jubilant.Juju): 113 | """Relate charm and wait for the expected changes in status.""" 114 | juju.integrate(APP_NAME, TEST_APP_NAME) 115 | juju.wait(jubilant.all_active) 116 | 117 | # Ensure data exists in the relation databag 118 | azure_credentials = get_application_data(juju, TEST_APP_NAME, RELATION_NAME) 119 | logger.debug(azure_credentials) 120 | 121 | assert "subscription-id" in azure_credentials 122 | assert "tenant-id" in azure_credentials 123 | assert "secret-extra" in azure_credentials 124 | 125 | assert azure_credentials["subscription-id"] == SUBSCRIPTION_ID_TEST_VALUE 126 | assert azure_credentials["tenant-id"] == TENANT_ID_TEST_VALUE 127 | 128 | secret_uri = azure_credentials["secret-extra"] 129 | secret_data = juju.show_secret(secret_uri, reveal=True) 130 | assert secret_data.content["client-id"] == CLIENT_ID_TEST_VALUE 131 | assert secret_data.content["client-secret"] == CLIENT_SECRET_TEST_VALUE 132 | 133 | # Ensure data exists in the requirer side 134 | result = juju.run(TEST_APP_UNIT_NAME, "get-azure-service-principal-info") 135 | assert result.results["subscription-id"] == SUBSCRIPTION_ID_TEST_VALUE 136 | assert result.results["tenant-id"] == TENANT_ID_TEST_VALUE 137 | assert result.results["client-id"] == CLIENT_ID_TEST_VALUE 138 | assert result.results["client-secret"] == CLIENT_SECRET_TEST_VALUE 139 | 140 | 141 | @pytest.mark.abort_on_fail 142 | def test_credentials_updated(juju: jubilant.Juju): 143 | """Tests updating the credentials and having the updates propagated to the relation.""" 144 | # Change the value of the config 145 | juju.config(APP_NAME, {"subscription-id": SUBSCRIPTION_ID_NEW_VALUE}) 146 | juju.wait(jubilant.all_active) 147 | 148 | # Ensure data exists in the relation databag 149 | azure_credentials = get_application_data(juju, TEST_APP_NAME, RELATION_NAME) 150 | assert azure_credentials["subscription-id"] == SUBSCRIPTION_ID_NEW_VALUE 151 | assert azure_credentials["tenant-id"] == TENANT_ID_TEST_VALUE 152 | 153 | # Ensure data exists in the requirer side 154 | result = juju.run(TEST_APP_UNIT_NAME, "get-azure-service-principal-info") 155 | assert result.results["subscription-id"] == SUBSCRIPTION_ID_NEW_VALUE 156 | assert result.results["tenant-id"] == TENANT_ID_TEST_VALUE 157 | 158 | # Change the value of the secret 159 | juju.update_secret( 160 | SECRET_IDENTIFIER, 161 | {"client-id": CLIENT_ID_TEST_VALUE, "client-secret": CLIENT_SECRET_NEW_VALUE}, 162 | ) 163 | juju.wait(jubilant.all_active) 164 | 165 | # Ensure data exists in the relation databag 166 | azure_credentials = get_application_data(juju, TEST_APP_NAME, RELATION_NAME) 167 | assert "subscription-id" in azure_credentials 168 | assert "tenant-id" in azure_credentials 169 | assert "secret-extra" in azure_credentials 170 | secret_uri = azure_credentials["secret-extra"] 171 | secret_data = juju.show_secret(secret_uri, reveal=True) 172 | assert secret_data.content["client-id"] == CLIENT_ID_TEST_VALUE 173 | assert secret_data.content["client-secret"] == CLIENT_SECRET_NEW_VALUE 174 | 175 | # Ensure data exists in the requirer side 176 | result = juju.run(TEST_APP_UNIT_NAME, "get-azure-service-principal-info") 177 | assert result.results["client-id"] == CLIENT_ID_TEST_VALUE 178 | assert result.results["client-secret"] == CLIENT_SECRET_NEW_VALUE 179 | 180 | 181 | @pytest.mark.abort_on_fail 182 | def test_relation_broken(juju: jubilant.Juju): 183 | """Removes relation and waits for the expected changes in status.""" 184 | juju.remove_relation(APP_NAME, TEST_APP_NAME) 185 | 186 | juju.wait(lambda status: jubilant.all_active(status, APP_NAME), error=jubilant.any_error) 187 | 188 | # Test that charm's status changes to Blocked 189 | juju.wait(lambda status: jubilant.all_blocked(status, TEST_APP_NAME), error=jubilant.any_error) 190 | -------------------------------------------------------------------------------- /src/lib/azure_service_principal.py: -------------------------------------------------------------------------------- 1 | """Logic for the provider and requirer side of the azure_service_principal interface.""" 2 | 3 | import logging 4 | from typing import Dict, List 5 | 6 | from charms.data_platform_libs.v0.data_interfaces import ( 7 | EventHandlers, 8 | ProviderData, 9 | RequirerData, 10 | RequirerEventHandlers, 11 | ) 12 | from ops import Model 13 | from ops.charm import ( 14 | CharmBase, 15 | CharmEvents, 16 | RelationBrokenEvent, 17 | RelationChangedEvent, 18 | RelationEvent, 19 | RelationJoinedEvent, 20 | SecretChangedEvent, 21 | ) 22 | from ops.framework import EventSource 23 | from ops.model import Relation 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | AZURE_SERVICE_PRINCIPAL_REQUIRED_INFO = [ 29 | "subscription-id", 30 | "tenant-id", 31 | "client-id", 32 | "client-secret", 33 | ] 34 | 35 | 36 | class ServicePrincipalEvent(RelationEvent): 37 | """Base class for Azure service principal events.""" 38 | 39 | pass 40 | 41 | 42 | class ServicePrincipalInfoRequestedEvent(ServicePrincipalEvent): 43 | """Event for requesting data from the interface.""" 44 | 45 | pass 46 | 47 | 48 | class ServicePrincipalInfoChangedEvent(ServicePrincipalEvent): 49 | """Event for changing data from the interface.""" 50 | 51 | pass 52 | 53 | 54 | class ServicePrincipalInfoGoneEvent(ServicePrincipalEvent): 55 | """Event for the removal of data from the interface.""" 56 | 57 | pass 58 | 59 | 60 | class AzureServicePrincipalProviderEvents(CharmEvents): 61 | """Events for the AzureServicePrincipalProvider side implementation.""" 62 | 63 | service_principal_info_requested = EventSource(ServicePrincipalInfoRequestedEvent) 64 | 65 | 66 | class AzureServicePrincipalRequirerEvents(CharmEvents): 67 | """Events for the AzureServicePrincipalRequirer side implementation.""" 68 | 69 | service_principal_info_changed = EventSource(ServicePrincipalInfoChangedEvent) 70 | service_principal_info_gone = EventSource(ServicePrincipalInfoGoneEvent) 71 | 72 | 73 | class AzureServicePrincipalRequirerData(RequirerData): 74 | """Data abstraction of the requirer side of Azure service principal relation.""" 75 | 76 | SECRET_FIELDS = ["client-id", "client-secret"] 77 | 78 | def __init__(self, model, relation_name: str): 79 | super().__init__( 80 | model, 81 | relation_name, 82 | ) 83 | 84 | 85 | class AzureServicePrincipalRequirerEventHandlers(RequirerEventHandlers): 86 | """Event handlers for for requirer side of Azure service principal relation.""" 87 | 88 | on = AzureServicePrincipalRequirerEvents() # pyright: ignore[reportAssignmentType] 89 | 90 | def __init__(self, charm: CharmBase, relation_data: AzureServicePrincipalRequirerData): 91 | super().__init__(charm, relation_data) 92 | 93 | self.relation_name = relation_data.relation_name 94 | self.charm = charm 95 | self.local_app = self.charm.model.app 96 | self.local_unit = self.charm.unit 97 | 98 | self.framework.observe( 99 | self.charm.on[self.relation_name].relation_joined, self._on_relation_joined_event 100 | ) 101 | self.framework.observe( 102 | self.charm.on[self.relation_name].relation_changed, self._on_relation_changed_event 103 | ) 104 | 105 | self.framework.observe( 106 | self.charm.on[self.relation_name].relation_broken, 107 | self._on_relation_broken_event, 108 | ) 109 | 110 | def _on_relation_joined_event(self, event: RelationJoinedEvent) -> None: 111 | pass 112 | 113 | def get_azure_service_principal_info(self) -> Dict[str, str]: 114 | """Return the Azure service principal info as a dictionary.""" 115 | for relation in self.relations: 116 | if relation and relation.app: 117 | info = self.relation_data.fetch_relation_data([relation.id])[relation.id] 118 | if not all(param in info for param in AZURE_SERVICE_PRINCIPAL_REQUIRED_INFO): 119 | continue 120 | return info 121 | return {} 122 | 123 | def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: 124 | """Notify the charm about the presence of Azure service principal credentials.""" 125 | logger.info(f"Azure service principal relation ({event.relation.name}) changed...") 126 | 127 | diff = self._diff(event) 128 | if any(newval for newval in diff.added if self.relation_data._is_secret_field(newval)): 129 | self.relation_data._register_secrets_to_relation(event.relation, diff.added) 130 | 131 | # check if the mandatory options are in the relation data 132 | contains_required_options = True 133 | credentials = self.get_azure_service_principal_info() 134 | missing_options = [] 135 | for configuration_option in AZURE_SERVICE_PRINCIPAL_REQUIRED_INFO: 136 | if configuration_option not in credentials: 137 | contains_required_options = False 138 | missing_options.append(configuration_option) 139 | 140 | # emit credential change event only if all mandatory fields are present 141 | if contains_required_options: 142 | getattr(self.on, "service_principal_info_changed").emit( 143 | event.relation, app=event.app, unit=event.unit 144 | ) 145 | else: 146 | logger.warning( 147 | f"Some mandatory fields: {missing_options} are not present, do not emit credential change event!" 148 | ) 149 | 150 | def _on_secret_changed_event(self, event: SecretChangedEvent): 151 | """Event handler for handling a new value of a secret.""" 152 | pass 153 | 154 | def _on_relation_broken_event(self, event: RelationBrokenEvent) -> None: 155 | """Event handler for handling relation_broken event.""" 156 | logger.info("Azure service principal relation broken...") 157 | getattr(self.on, "service_principal_info_gone").emit( 158 | event.relation, app=event.app, unit=event.unit 159 | ) 160 | 161 | @property 162 | def relations(self) -> List[Relation]: 163 | """The list of Relation instances associated with this relation_name.""" 164 | return list(self.charm.model.relations[self.relation_name]) 165 | 166 | 167 | class AzureServicePrincipalRequirer( 168 | AzureServicePrincipalRequirerData, AzureServicePrincipalRequirerEventHandlers 169 | ): 170 | """The requirer side of Azure service principal relation.""" 171 | 172 | def __init__( 173 | self, 174 | charm: CharmBase, 175 | relation_name: str, 176 | ): 177 | AzureServicePrincipalRequirerData.__init__(self, charm.model, relation_name) 178 | AzureServicePrincipalRequirerEventHandlers.__init__(self, charm, self) 179 | 180 | 181 | class AzureServicePrincipalProviderData(ProviderData): 182 | """The Data abstraction of the provider side of Azure service principal relation.""" 183 | 184 | def __init__(self, model: Model, relation_name: str) -> None: 185 | super().__init__(model, relation_name) 186 | 187 | # Override the method to bypass the parent's validation that raises PrematureDataAccessError. 188 | def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None: 189 | super(ProviderData, self)._update_relation_data(relation, data) 190 | 191 | 192 | class AzureServicePrincipalProviderEventHandlers(EventHandlers): 193 | """The event handlers related to provider side of Azure service principal relation.""" 194 | 195 | on = AzureServicePrincipalProviderEvents() 196 | 197 | def __init__( 198 | self, 199 | charm: CharmBase, 200 | relation_data: AzureServicePrincipalProviderData, 201 | unique_key: str = "", 202 | ): 203 | super().__init__(charm, relation_data, unique_key) 204 | self.relation_name = relation_data.relation_name 205 | 206 | self.framework.observe( 207 | self.charm.on[self.relation_name].relation_joined, self._on_relation_joined_event 208 | ) 209 | 210 | self.framework.observe( 211 | self.charm.on[self.relation_name].relation_changed, self._on_relation_changed_event 212 | ) 213 | 214 | self.framework.observe(self.charm.on.secret_changed, self._on_secret_changed_event) 215 | 216 | def _on_relation_joined_event(self, event: RelationJoinedEvent): 217 | logger.warning("Calling relation joined method...") 218 | if not self.charm.unit.is_leader(): 219 | return 220 | self.on.service_principal_info_requested.emit( 221 | event.relation, app=event.app, unit=event.unit 222 | ) 223 | 224 | def _on_relation_changed_event(self, event: RelationChangedEvent): 225 | pass 226 | 227 | def _on_secret_changed_event(self, event: SecretChangedEvent): 228 | pass 229 | 230 | 231 | class AzureServicePrincipalProvider( 232 | AzureServicePrincipalProviderData, AzureServicePrincipalProviderEventHandlers 233 | ): 234 | """The provider side of the Azure service principal relation.""" 235 | 236 | def __init__(self, charm: CharmBase, relation_name: str) -> None: 237 | AzureServicePrincipalProviderData.__init__(self, charm.model, relation_name) 238 | AzureServicePrincipalProviderEventHandlers.__init__(self, charm, self) 239 | -------------------------------------------------------------------------------- /tests/integration/test-charm-azure-service-principal/src/lib/azure_service_principal.py: -------------------------------------------------------------------------------- 1 | """Logic for the provider and requirer side of the azure_service_principal interface.""" 2 | 3 | import logging 4 | from typing import Dict, List 5 | 6 | from charms.data_platform_libs.v0.data_interfaces import ( 7 | EventHandlers, 8 | ProviderData, 9 | RequirerData, 10 | RequirerEventHandlers, 11 | ) 12 | from ops import Model 13 | from ops.charm import ( 14 | CharmBase, 15 | CharmEvents, 16 | RelationBrokenEvent, 17 | RelationChangedEvent, 18 | RelationEvent, 19 | RelationJoinedEvent, 20 | SecretChangedEvent, 21 | ) 22 | from ops.framework import EventSource 23 | from ops.model import Relation 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | AZURE_SERVICE_PRINCIPAL_REQUIRED_INFO = [ 29 | "subscription-id", 30 | "tenant-id", 31 | "client-id", 32 | "client-secret", 33 | ] 34 | 35 | 36 | class ServicePrincipalEvent(RelationEvent): 37 | """Base class for Azure service principal events.""" 38 | 39 | pass 40 | 41 | 42 | class ServicePrincipalInfoRequestedEvent(ServicePrincipalEvent): 43 | """Event for requesting data from the interface.""" 44 | 45 | pass 46 | 47 | 48 | class ServicePrincipalInfoChangedEvent(ServicePrincipalEvent): 49 | """Event for changing data from the interface.""" 50 | 51 | pass 52 | 53 | 54 | class ServicePrincipalInfoGoneEvent(ServicePrincipalEvent): 55 | """Event for the removal of data from the interface.""" 56 | 57 | pass 58 | 59 | 60 | class AzureServicePrincipalProviderEvents(CharmEvents): 61 | """Events for the AzureServicePrincipalProvider side implementation.""" 62 | 63 | service_principal_info_requested = EventSource(ServicePrincipalInfoRequestedEvent) 64 | 65 | 66 | class AzureServicePrincipalRequirerEvents(CharmEvents): 67 | """Events for the AzureServicePrincipalRequirer side implementation.""" 68 | 69 | service_principal_info_changed = EventSource(ServicePrincipalInfoChangedEvent) 70 | service_principal_info_gone = EventSource(ServicePrincipalInfoGoneEvent) 71 | 72 | 73 | class AzureServicePrincipalRequirerData(RequirerData): 74 | """Data abstraction of the requirer side of Azure service principal relation.""" 75 | 76 | SECRET_FIELDS = ["client-id", "client-secret"] 77 | 78 | def __init__(self, model, relation_name: str): 79 | super().__init__( 80 | model, 81 | relation_name, 82 | ) 83 | 84 | 85 | class AzureServicePrincipalRequirerEventHandlers(RequirerEventHandlers): 86 | """Event handlers for for requirer side of Azure service principal relation.""" 87 | 88 | on = AzureServicePrincipalRequirerEvents() # pyright: ignore[reportAssignmentType] 89 | 90 | def __init__(self, charm: CharmBase, relation_data: AzureServicePrincipalRequirerData): 91 | super().__init__(charm, relation_data) 92 | 93 | self.relation_name = relation_data.relation_name 94 | self.charm = charm 95 | self.local_app = self.charm.model.app 96 | self.local_unit = self.charm.unit 97 | 98 | self.framework.observe( 99 | self.charm.on[self.relation_name].relation_joined, self._on_relation_joined_event 100 | ) 101 | self.framework.observe( 102 | self.charm.on[self.relation_name].relation_changed, self._on_relation_changed_event 103 | ) 104 | 105 | self.framework.observe( 106 | self.charm.on[self.relation_name].relation_broken, 107 | self._on_relation_broken_event, 108 | ) 109 | 110 | def _on_relation_joined_event(self, event: RelationJoinedEvent) -> None: 111 | pass 112 | 113 | def get_azure_service_principal_info(self) -> Dict[str, str]: 114 | """Return the Azure service principal info as a dictionary.""" 115 | for relation in self.relations: 116 | if relation and relation.app: 117 | info = self.relation_data.fetch_relation_data([relation.id])[relation.id] 118 | if not all(param in info for param in AZURE_SERVICE_PRINCIPAL_REQUIRED_INFO): 119 | continue 120 | return info 121 | return {} 122 | 123 | def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: 124 | """Notify the charm about the presence of Azure service principal credentials.""" 125 | logger.info(f"Azure service principal relation ({event.relation.name}) changed...") 126 | 127 | diff = self._diff(event) 128 | if any(newval for newval in diff.added if self.relation_data._is_secret_field(newval)): 129 | self.relation_data._register_secrets_to_relation(event.relation, diff.added) 130 | 131 | # check if the mandatory options are in the relation data 132 | contains_required_options = True 133 | credentials = self.get_azure_service_principal_info() 134 | missing_options = [] 135 | for configuration_option in AZURE_SERVICE_PRINCIPAL_REQUIRED_INFO: 136 | if configuration_option not in credentials: 137 | contains_required_options = False 138 | missing_options.append(configuration_option) 139 | 140 | # emit credential change event only if all mandatory fields are present 141 | if contains_required_options: 142 | getattr(self.on, "service_principal_info_changed").emit( 143 | event.relation, app=event.app, unit=event.unit 144 | ) 145 | else: 146 | logger.warning( 147 | f"Some mandatory fields: {missing_options} are not present, do not emit credential change event!" 148 | ) 149 | 150 | def _on_secret_changed_event(self, event: SecretChangedEvent): 151 | """Event handler for handling a new value of a secret.""" 152 | pass 153 | 154 | def _on_relation_broken_event(self, event: RelationBrokenEvent) -> None: 155 | """Event handler for handling relation_broken event.""" 156 | logger.info("Azure service principal relation broken...") 157 | getattr(self.on, "service_principal_info_gone").emit( 158 | event.relation, app=event.app, unit=event.unit 159 | ) 160 | 161 | @property 162 | def relations(self) -> List[Relation]: 163 | """The list of Relation instances associated with this relation_name.""" 164 | return list(self.charm.model.relations[self.relation_name]) 165 | 166 | 167 | class AzureServicePrincipalRequirer( 168 | AzureServicePrincipalRequirerData, AzureServicePrincipalRequirerEventHandlers 169 | ): 170 | """The requirer side of Azure service principal relation.""" 171 | 172 | def __init__( 173 | self, 174 | charm: CharmBase, 175 | relation_name: str, 176 | ): 177 | AzureServicePrincipalRequirerData.__init__(self, charm.model, relation_name) 178 | AzureServicePrincipalRequirerEventHandlers.__init__(self, charm, self) 179 | 180 | 181 | class AzureServicePrincipalProviderData(ProviderData): 182 | """The Data abstraction of the provider side of Azure service principal relation.""" 183 | 184 | def __init__(self, model: Model, relation_name: str) -> None: 185 | super().__init__(model, relation_name) 186 | 187 | # Override the method to bypass the parent's validation that raises PrematureDataAccessError. 188 | def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None: 189 | super(ProviderData, self)._update_relation_data(relation, data) 190 | 191 | 192 | class AzureServicePrincipalProviderEventHandlers(EventHandlers): 193 | """The event handlers related to provider side of Azure service principal relation.""" 194 | 195 | on = AzureServicePrincipalProviderEvents() 196 | 197 | def __init__( 198 | self, 199 | charm: CharmBase, 200 | relation_data: AzureServicePrincipalProviderData, 201 | unique_key: str = "", 202 | ): 203 | super().__init__(charm, relation_data, unique_key) 204 | self.relation_name = relation_data.relation_name 205 | 206 | self.framework.observe( 207 | self.charm.on[self.relation_name].relation_joined, self._on_relation_joined_event 208 | ) 209 | 210 | self.framework.observe( 211 | self.charm.on[self.relation_name].relation_changed, self._on_relation_changed_event 212 | ) 213 | 214 | self.framework.observe(self.charm.on.secret_changed, self._on_secret_changed_event) 215 | 216 | def _on_relation_joined_event(self, event: RelationJoinedEvent): 217 | logger.warning("Calling relation joined method...") 218 | if not self.charm.unit.is_leader(): 219 | return 220 | self.on.service_principal_info_requested.emit( 221 | event.relation, app=event.app, unit=event.unit 222 | ) 223 | 224 | def _on_relation_changed_event(self, event: RelationChangedEvent): 225 | pass 226 | 227 | def _on_secret_changed_event(self, event: SecretChangedEvent): 228 | pass 229 | 230 | 231 | class AzureServicePrincipalProvider( 232 | AzureServicePrincipalProviderData, AzureServicePrincipalProviderEventHandlers 233 | ): 234 | """The provider side of the Azure service principal relation.""" 235 | 236 | def __init__(self, charm: CharmBase, relation_name: str) -> None: 237 | AzureServicePrincipalProviderData.__init__(self, charm.model, relation_name) 238 | AzureServicePrincipalProviderEventHandlers.__init__(self, charm, self) 239 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 Canonical Limited 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------