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 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 22:16:48 +02:00
parent 7738a51d16
commit 0f1a6d865d
10 changed files with 251 additions and 3 deletions
+19
View File
@@ -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.
+7 -1
View File
@@ -1,7 +1,9 @@
# apim-apps # 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. 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 ## How it works
@@ -75,12 +77,16 @@ applications:
enabled: true enabled: true
environments: environments:
- DEV_LAN - DEV_LAN
credentials:
- client_id: "my-external-client-id" # static external credential; optional secret:
apis: apis:
- name: "Products" - name: "Products"
version: "v2" version: "v2"
- "Notifications" # short form — no version filter - "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: If you also want the tool to manage organisations, add an `organizations` section:
```yaml ```yaml
+19
View File
@@ -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.
+1 -1
View File
@@ -1,5 +1,5 @@
from .client import AxwayClient, _AppendOnlySession from .client import AxwayClient, _AppendOnlySession
from .engine import normalize_env from .engine import normalize_env
from .loader import EXAMPLE_CONFIG, EXAMPLE_MANIFEST, load_config, load_manifest 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 from .report import print_report
+6
View File
@@ -98,6 +98,12 @@ class AxwayClient:
"state": "approved", "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 ──────────────────────────────────────────────────────── # ── APIs front-end ────────────────────────────────────────────────────────
def list_frontend_apis(self) -> list[dict]: def list_frontend_apis(self) -> list[dict]:
+10
View File
@@ -102,6 +102,16 @@ def normalize_env(env: EnvConfig, manifest: Manifest, dry_run: bool) -> NormResu
log.error(msg) log.error(msg)
result.errors.append(msg) result.errors.append(msg)
continue 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) result.actions_done.append(action_app)
else: else:
result.actions_skipped.append(action_app) result.actions_skipped.append(action_app)
+9 -1
View File
@@ -3,7 +3,7 @@ import textwrap
import yaml import yaml
from .models import ApiAccess, AppSpec, EnvConfig, Manifest, OrgSpec from .models import ApiAccess, AppCredential, AppSpec, EnvConfig, Manifest, OrgSpec
EXAMPLE_MANIFEST = textwrap.dedent("""\ EXAMPLE_MANIFEST = textwrap.dedent("""\
# ============================================================================= # =============================================================================
@@ -26,6 +26,8 @@ applications:
description: "Application mobile grand public" description: "Application mobile grand public"
email: "mobile-app@example.com" email: "mobile-app@example.com"
enabled: true enabled: true
credentials:
- client_id: "my-external-client-id"
apis: apis:
- name: "Catalogue-Produits" - name: "Catalogue-Produits"
version: "v2" version: "v2"
@@ -96,6 +98,11 @@ def load_manifest(path: str) -> Manifest:
apis.append(ApiAccess(api_name=api)) apis.append(ApiAccess(api_name=api))
else: else:
apis.append(ApiAccess(api_name=api["name"], api_version=api.get("version", ""))) 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( manifest.applications.append(AppSpec(
name=a["name"], name=a["name"],
org_name=a["organization"], org_name=a["organization"],
@@ -105,6 +112,7 @@ def load_manifest(path: str) -> Manifest:
enabled=a.get("enabled", True), enabled=a.get("enabled", True),
apis=apis, apis=apis,
environments=a.get("environments", []), environments=a.get("environments", []),
credentials=credentials,
)) ))
return manifest return manifest
+7
View File
@@ -7,6 +7,12 @@ class ApiAccess:
api_version: str = "" api_version: str = ""
@dataclass
class AppCredential:
client_id: str
secret: str = ""
@dataclass @dataclass
class AppSpec: class AppSpec:
name: str name: str
@@ -17,6 +23,7 @@ class AppSpec:
enabled: bool = True enabled: bool = True
apis: list["ApiAccess"] = field(default_factory=list) apis: list["ApiAccess"] = field(default_factory=list)
environments: list[str] = field(default_factory=list) environments: list[str] = field(default_factory=list)
credentials: list["AppCredential"] = field(default_factory=list)
@dataclass @dataclass
+2
View File
@@ -18,6 +18,8 @@ applications:
description: "Application mobile grand public" description: "Application mobile grand public"
email: "mobile-app@example.com" email: "mobile-app@example.com"
enabled: true enabled: true
credentials:
- client_id: "my-external-client-id"
apis: apis:
- name: "Catalogue-Produits" - name: "Catalogue-Produits"
version: "v2" version: "v2"
+171
View File
@@ -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)