From 0f1a6d865dc126fff27ad9fc5a673bf4badfd4d4 Mon Sep 17 00:00:00 2001 From: k3nny Date: Mon, 29 Jun 2026 22:16:48 +0200 Subject: [PATCH] feat(apim-apps): add external credentials support for new applications Applications can now declare a `credentials:` list in the YAML manifest. Each entry holds a static `client_id` (and optional `secret`) that is provisioned via POST /applications/{id}/extcredentials when the application is first created. Existing applications are never modified. Also adds CHANGELOG.md and ROADMAP.md for the v0.1.0 initial release. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 19 +++++ README.md | 8 +- ROADMAP.md | 19 +++++ apim_apps/__init__.py | 2 +- apim_apps/client.py | 6 ++ apim_apps/engine.py | 10 +++ apim_apps/loader.py | 10 ++- apim_apps/models.py | 7 ++ apps.yaml | 2 + tests/test_credentials.py | 171 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 ROADMAP.md create mode 100644 tests/test_credentials.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..04613cf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-06-29 + +### Added + +- **External credentials** — applications can declare a `credentials:` list in the manifest; each entry sets a static `client_id` (and optional `secret`) via `POST /applications/{id}/extcredentials` when the application is first created. Existing applications are never touched. +- **Application management** — creates missing client applications in Axway API Manager from a YAML manifest, scoped to specific environments via `environments:`. +- **Organisation management** — optional `organizations:` section to declaratively manage organisations alongside applications. +- **API subscriptions** — automatically grants front-end API access to newly created applications. +- **Multi-environment support** — runs against all configured environments in `config.json`, or a single one with `--env`. +- **Dry-run mode** — `--dry-run` simulates all actions without making any changes. +- **Append-only safety** — the HTTP session blocks DELETE requests at the transport level; no resource can be removed by this tool. +- **GitLab CI/CD integration** — example pipeline included. diff --git a/README.md b/README.md index e920ca9..1ecadbc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # apim-apps +![release](https://img.shields.io/badge/release-v0.1.0-blue.svg) + CLI tool for managing Axway API Manager client applications from a YAML manifest. -Ensures compliance across multiple environments by creating missing applications and API subscriptions. +Ensures compliance across multiple environments by creating missing applications, external credentials, and API subscriptions. ## How it works @@ -75,12 +77,16 @@ applications: enabled: true environments: - DEV_LAN + credentials: + - client_id: "my-external-client-id" # static external credential; optional secret: apis: - name: "Products" version: "v2" - "Notifications" # short form — no version filter ``` +The `credentials` list is optional. Each entry provisions an external credential (`client_id`, optional `secret`) on the application via the API Manager's `/extcredentials` endpoint. Credentials are set **only when the application is first created** — existing applications are never modified. + If you also want the tool to manage organisations, add an `organizations` section: ```yaml diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..9db8ba2 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,19 @@ +# Roadmap + +## Shipped in v0.1.0 + +- ~~**Application management**~~ — ✓ shipped v0.1.0 +- ~~**Organisation management**~~ — ✓ shipped v0.1.0 +- ~~**API subscriptions**~~ — ✓ shipped v0.1.0 +- ~~**External credentials**~~ — ✓ shipped v0.1.0 (static `client_id`/`secret` per application, set on creation only) +- ~~**Multi-environment support**~~ — ✓ shipped v0.1.0 +- ~~**Dry-run mode**~~ — ✓ shipped v0.1.0 +- ~~**Append-only safety**~~ — ✓ shipped v0.1.0 + +## Backlog + +- **Update existing applications** — detect drift between manifest and API Manager state and reconcile (currently, only missing resources are created). +- **OAuth credentials** — full OAuth client credential provisioning (`clientId` + `secret` via `/oauth`). +- **Update existing credentials** — reconcile `credentials:` on applications that already exist. +- **Environment variable interpolation** — allow `${VAR}` references in the manifest for values that differ between environments. +- **Report output** — structured JSON/YAML report of actions taken, suitable for CI artefacts. diff --git a/apim_apps/__init__.py b/apim_apps/__init__.py index ef52c74..75757a2 100644 --- a/apim_apps/__init__.py +++ b/apim_apps/__init__.py @@ -1,5 +1,5 @@ from .client import AxwayClient, _AppendOnlySession from .engine import normalize_env from .loader import EXAMPLE_CONFIG, EXAMPLE_MANIFEST, load_config, load_manifest -from .models import Action, ApiAccess, AppSpec, EnvConfig, Manifest, NormResult, OrgSpec +from .models import Action, ApiAccess, AppCredential, AppSpec, EnvConfig, Manifest, NormResult, OrgSpec from .report import print_report diff --git a/apim_apps/client.py b/apim_apps/client.py index 596a2e2..85cbe0c 100644 --- a/apim_apps/client.py +++ b/apim_apps/client.py @@ -98,6 +98,12 @@ class AxwayClient: "state": "approved", }) + def create_app_ext_credential(self, app_id: str, client_id: str, secret: str = "") -> dict: + payload: dict = {"clientId": client_id, "enabled": True} + if secret: + payload["secret"] = secret + return self._post(f"/applications/{app_id}/extcredentials", payload) + # ── APIs front-end ──────────────────────────────────────────────────────── def list_frontend_apis(self) -> list[dict]: diff --git a/apim_apps/engine.py b/apim_apps/engine.py index 44da220..d723674 100644 --- a/apim_apps/engine.py +++ b/apim_apps/engine.py @@ -102,6 +102,16 @@ def normalize_env(env: EnvConfig, manifest: Manifest, dry_run: bool) -> NormResu log.error(msg) result.errors.append(msg) continue + + app_id_new = created_app.get("id", "") + for cred in app_spec.credentials: + try: + client.create_app_ext_credential(app_id_new, cred.client_id, cred.secret) + log.info(_c("green", f"{tag} ✅ External credential « {cred.client_id} » ajouté à « {app_spec.name} »")) + except Exception as e: + msg = f"{tag} ❌ Échec ajout external credential à « {app_spec.name} » : {e}" + log.error(msg) + result.errors.append(msg) result.actions_done.append(action_app) else: result.actions_skipped.append(action_app) diff --git a/apim_apps/loader.py b/apim_apps/loader.py index cd8f87c..7e8ebf6 100644 --- a/apim_apps/loader.py +++ b/apim_apps/loader.py @@ -3,7 +3,7 @@ import textwrap import yaml -from .models import ApiAccess, AppSpec, EnvConfig, Manifest, OrgSpec +from .models import ApiAccess, AppCredential, AppSpec, EnvConfig, Manifest, OrgSpec EXAMPLE_MANIFEST = textwrap.dedent("""\ # ============================================================================= @@ -26,6 +26,8 @@ applications: description: "Application mobile grand public" email: "mobile-app@example.com" enabled: true + credentials: + - client_id: "my-external-client-id" apis: - name: "Catalogue-Produits" version: "v2" @@ -96,6 +98,11 @@ def load_manifest(path: str) -> Manifest: apis.append(ApiAccess(api_name=api)) else: apis.append(ApiAccess(api_name=api["name"], api_version=api.get("version", ""))) + credentials = [ + AppCredential(client_id=c["client_id"], secret=c.get("secret", "")) + for c in a.get("credentials", []) + if c.get("client_id") + ] manifest.applications.append(AppSpec( name=a["name"], org_name=a["organization"], @@ -105,6 +112,7 @@ def load_manifest(path: str) -> Manifest: enabled=a.get("enabled", True), apis=apis, environments=a.get("environments", []), + credentials=credentials, )) return manifest diff --git a/apim_apps/models.py b/apim_apps/models.py index c3238a7..fd488a6 100644 --- a/apim_apps/models.py +++ b/apim_apps/models.py @@ -7,6 +7,12 @@ class ApiAccess: api_version: str = "" +@dataclass +class AppCredential: + client_id: str + secret: str = "" + + @dataclass class AppSpec: name: str @@ -17,6 +23,7 @@ class AppSpec: enabled: bool = True apis: list["ApiAccess"] = field(default_factory=list) environments: list[str] = field(default_factory=list) + credentials: list["AppCredential"] = field(default_factory=list) @dataclass diff --git a/apps.yaml b/apps.yaml index 4eca750..6b7fdbb 100644 --- a/apps.yaml +++ b/apps.yaml @@ -18,6 +18,8 @@ applications: description: "Application mobile grand public" email: "mobile-app@example.com" enabled: true + credentials: + - client_id: "my-external-client-id" apis: - name: "Catalogue-Produits" version: "v2" diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 0000000..b0cc764 --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,171 @@ +import textwrap +import pytest +from unittest.mock import patch +from apim_apps import load_manifest, AppCredential +from apim_apps import normalize_env, EnvConfig, Manifest, OrgSpec, AppSpec + + +# --------------------------------------------------------------------------- +# Loader — parsing credentials from YAML +# --------------------------------------------------------------------------- + +WITH_CREDENTIAL = textwrap.dedent("""\ + organizations: [] + applications: + - name: "MyApp" + organization: "MyOrg" + credentials: + - client_id: "ext-client-id" +""") + +WITH_SECRET = textwrap.dedent("""\ + organizations: [] + applications: + - name: "MyApp" + organization: "MyOrg" + credentials: + - client_id: "ext-client-id" + secret: "s3cr3t" +""") + +MULTIPLE_CREDENTIALS = textwrap.dedent("""\ + organizations: [] + applications: + - name: "MyApp" + organization: "MyOrg" + credentials: + - client_id: "client-one" + - client_id: "client-two" +""") + +WITHOUT_CREDENTIAL = textwrap.dedent("""\ + organizations: [] + applications: + - name: "MyApp" + organization: "MyOrg" +""") + + +def test_credential_is_parsed(tmp_path): + f = tmp_path / "apps.yaml" + f.write_text(WITH_CREDENTIAL) + m = load_manifest(str(f)) + assert m.applications[0].credentials == [AppCredential(client_id="ext-client-id")] + + +def test_credential_with_secret_parsed(tmp_path): + f = tmp_path / "apps.yaml" + f.write_text(WITH_SECRET) + m = load_manifest(str(f)) + assert m.applications[0].credentials == [AppCredential(client_id="ext-client-id", secret="s3cr3t")] + + +def test_multiple_credentials_parsed(tmp_path): + f = tmp_path / "apps.yaml" + f.write_text(MULTIPLE_CREDENTIALS) + m = load_manifest(str(f)) + assert m.applications[0].credentials == [ + AppCredential(client_id="client-one"), + AppCredential(client_id="client-two"), + ] + + +def test_credentials_default_to_empty(tmp_path): + f = tmp_path / "apps.yaml" + f.write_text(WITHOUT_CREDENTIAL) + m = load_manifest(str(f)) + assert m.applications[0].credentials == [] + + +# --------------------------------------------------------------------------- +# Engine — credentials set on new apps only, never on existing or dry-run +# --------------------------------------------------------------------------- + +@pytest.fixture +def env(): + return EnvConfig(name="TEST", url="https://test.example.com:8075", username="u", password="p") + + +@pytest.fixture +def mock_client(): + with patch("apim_apps.engine.AxwayClient") as MockClient: + instance = MockClient.return_value + instance.list_orgs.return_value = {"MyOrg": {"id": "org-1", "name": "MyOrg"}} + instance.list_apps.return_value = {} + instance.list_app_api_access.return_value = [] + instance.create_app.return_value = {"id": "app-1", "name": "MyApp"} + yield instance + + +def _app(**kwargs): + return AppSpec(name="MyApp", org_name="MyOrg", **kwargs) + + +def test_ext_credential_created_for_new_app(env, mock_client): + manifest = Manifest( + applications=[_app(credentials=[AppCredential(client_id="ext-id")])] + ) + result = normalize_env(env, manifest, dry_run=False) + + mock_client.create_app_ext_credential.assert_called_once_with("app-1", "ext-id", "") + assert len(result.errors) == 0 + + +def test_ext_credential_with_secret(env, mock_client): + manifest = Manifest( + applications=[_app(credentials=[AppCredential(client_id="ext-id", secret="s3cr3t")])] + ) + normalize_env(env, manifest, dry_run=False) + + mock_client.create_app_ext_credential.assert_called_once_with("app-1", "ext-id", "s3cr3t") + + +def test_multiple_ext_credentials_created(env, mock_client): + manifest = Manifest( + applications=[_app(credentials=[ + AppCredential(client_id="client-one"), + AppCredential(client_id="client-two"), + ])] + ) + normalize_env(env, manifest, dry_run=False) + + assert mock_client.create_app_ext_credential.call_count == 2 + mock_client.create_app_ext_credential.assert_any_call("app-1", "client-one", "") + mock_client.create_app_ext_credential.assert_any_call("app-1", "client-two", "") + + +def test_no_ext_credential_when_credentials_absent(env, mock_client): + manifest = Manifest(applications=[_app()]) + normalize_env(env, manifest, dry_run=False) + + mock_client.create_app_ext_credential.assert_not_called() + + +def test_ext_credential_not_set_on_existing_app(env, mock_client): + mock_client.list_apps.return_value = {("MyOrg", "MyApp"): {"id": "app-1", "name": "MyApp"}} + manifest = Manifest( + applications=[_app(credentials=[AppCredential(client_id="ext-id")])] + ) + normalize_env(env, manifest, dry_run=False) + + mock_client.create_app.assert_not_called() + mock_client.create_app_ext_credential.assert_not_called() + + +def test_ext_credential_not_set_in_dry_run(env, mock_client): + manifest = Manifest( + applications=[_app(credentials=[AppCredential(client_id="ext-id")])] + ) + normalize_env(env, manifest, dry_run=True) + + mock_client.create_app_ext_credential.assert_not_called() + + +def test_credential_error_recorded_in_result(env, mock_client): + mock_client.create_app_ext_credential.side_effect = RuntimeError("API failure") + manifest = Manifest( + applications=[_app(credentials=[AppCredential(client_id="bad-id")])] + ) + result = normalize_env(env, manifest, dry_run=False) + + assert any("MyApp" in e for e in result.errors)