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:
@@ -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.
|
||||
@@ -1,7 +1,9 @@
|
||||
# apim-apps
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
+19
@@ -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,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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user