aac-cli 0.1.2__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aac_cli-0.1.2 → aac_cli-0.1.3}/PKG-INFO +46 -9
- {aac_cli-0.1.2 → aac_cli-0.1.3}/README.md +45 -8
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/__init__.py +1 -1
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/cli.py +202 -26
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/config.py +45 -2
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/profiles.py +12 -2
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli.egg-info/PKG-INFO +46 -9
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli.egg-info/SOURCES.txt +1 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/pyproject.toml +1 -1
- aac_cli-0.1.3/tests/test_hosted_domains_cli.py +533 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/LICENSE +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/__main__.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/admin_key_pem.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/api_key_rotation_state.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/idp_recovery.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/registration_state.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/sso_login.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli.egg-info/dependency_links.txt +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli.egg-info/entry_points.txt +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli.egg-info/requires.txt +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli.egg-info/top_level.txt +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/setup.cfg +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_aac_cli.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_admin_key_pem.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_api_key_reissue_cli.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_api_key_rotation_cli.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_api_key_rotation_state.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_idp_recovery_cli.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_profile_cli.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_registration_recovery_cli.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_registration_state.py +0 -0
- {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_sso_login_cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aac-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: The aac platform CLI — headless operator interface to the AAC control plane (tenant registration, chain audit).
|
|
5
5
|
Author: Agent Authority Cloud Project
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -99,14 +99,14 @@ aac sso login --profile dev # thereafter: plain login
|
|
|
99
99
|
# Tenant ids are the server-allocated canonical tnt-<uuid> form
|
|
100
100
|
# (B154 PR 5) — captured at registration / shown by `aac profile show`.
|
|
101
101
|
aac tenant list # which tenant ids exist (no 409 probe)
|
|
102
|
-
aac tenant describe --
|
|
102
|
+
aac tenant describe --profile dev # assigned domain, workloads + key METADATA
|
|
103
103
|
aac tenant update --tenant-id tnt-<uuid> --display-name "ACME Manufacturing Inc"
|
|
104
104
|
|
|
105
105
|
# B121/B228 — business-domain control proof and bounded claim lifecycle.
|
|
106
106
|
# Registration can capture up to 16 pending rows with repeatable --tenant-domain.
|
|
107
107
|
# The issue/verify/release/revoke commands
|
|
108
108
|
# require the owning tenant's tenant-admin session and default --tenant-id from
|
|
109
|
-
# the profile;
|
|
109
|
+
# the profile; tenant describe also uses the selected profile by default.
|
|
110
110
|
aac tenant register --display-name "ACME" --contact ops@acme.example \
|
|
111
111
|
--tenant-domain acme.example
|
|
112
112
|
aac tenant issue-domain-challenge --domain acme.example
|
|
@@ -117,7 +117,7 @@ aac tenant verify-domain --domain acme.example
|
|
|
117
117
|
# never performs an implicit binding write.
|
|
118
118
|
aac tenant bind-trust-domain --trust-domain acme.example
|
|
119
119
|
aac tenant list-trust-domains --output table
|
|
120
|
-
aac tenant describe --
|
|
120
|
+
aac tenant describe --profile dev --output table # assigned binding status + TXT
|
|
121
121
|
aac tenant release-domain --domain acme.example --reason "planned transfer"
|
|
122
122
|
aac tenant revoke-domain --domain acme.example --reason "DNS control incident"
|
|
123
123
|
|
|
@@ -279,9 +279,9 @@ aac sso login --flow pkce --no-browser # print the URL, don't launch
|
|
|
279
279
|
# any admin verb tells you to re-run `aac sso login` — there is no
|
|
280
280
|
# refresh token to steal, and no silent re-authentication.
|
|
281
281
|
|
|
282
|
-
# B155 — session
|
|
283
|
-
#
|
|
284
|
-
aac sso whoami --
|
|
282
|
+
# B155/B239 — local session/profile inspection; no server call or JWT parsing.
|
|
283
|
+
# whoami exits 0 = unexpired cache, 3 = expired/none (§14).
|
|
284
|
+
aac sso whoami --profile dev --output table # cached session + saved assigned domain
|
|
285
285
|
aac sso logout --tenant-id tnt-550e8400-e29b-41d4-a716-446655440000
|
|
286
286
|
```
|
|
287
287
|
|
|
@@ -495,8 +495,9 @@ coexistence, Eng Spec §XVII.9):
|
|
|
495
495
|
expiry fails fast locally with the re-login message instead of
|
|
496
496
|
dialing out; the server's own session 401s carry the same
|
|
497
497
|
remediation (the server stays authoritative). `aac sso logout`
|
|
498
|
-
removes the file; `aac sso whoami` reports its tenant + expiry
|
|
499
|
-
|
|
498
|
+
removes the file; `aac sso whoami` reports its tenant + expiry and, when
|
|
499
|
+
available, the matching profile's saved AAC-assigned domain. Both commands
|
|
500
|
+
operate locally; `whoami` does not verify current server-side binding status.
|
|
500
501
|
|
|
501
502
|
## Your tenant_id
|
|
502
503
|
|
|
@@ -547,3 +548,39 @@ ceremony-only, and existing binding history remains B181.
|
|
|
547
548
|
needed. Against a live stack: bring up the joined topology
|
|
548
549
|
(`./bin/run-wedge-a-control-plane-compose.sh --keep-up`) and point the
|
|
549
550
|
flags at localhost.
|
|
551
|
+
|
|
552
|
+
## Assigned tenant domains (B239)
|
|
553
|
+
|
|
554
|
+
Registration automatically saves the server-assigned `hosted_trust_domain` in
|
|
555
|
+
the selected profile. The CLI displays the full name, explains its purpose in
|
|
556
|
+
SPIFFE identities/certificates/trust configuration, and prints the command for
|
|
557
|
+
finding it again. No manual copy is required. With JSON output, this guidance
|
|
558
|
+
goes to stderr and stdout remains one JSON document; table output includes it
|
|
559
|
+
inline. Completed-registration recovery provides the same saved-name guidance
|
|
560
|
+
without changing credential-reissue requirements.
|
|
561
|
+
|
|
562
|
+
```sh
|
|
563
|
+
aac profile show dev --output table # saved locally, offline
|
|
564
|
+
aac tenant describe --profile dev --output table # assigned name + current binding status
|
|
565
|
+
aac tenant list-trust-domains --profile dev --output table # current ACTIVE bindings
|
|
566
|
+
aac tenant list-trust-domains --profile dev --all --output table # includes revoked history
|
|
567
|
+
aac sso whoami --profile dev --output table # cached session + matching saved name
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
`tenant describe` accepts an optional `--tenant-id`; the usual flag → environment
|
|
571
|
+
→ selected profile precedence applies. Its response comes from AAC. Binding
|
|
572
|
+
table output labels AAC-assigned/custom origin, ACTIVE/REVOKED status and
|
|
573
|
+
inherited scope. Registration, describe, profile show and binding-list JSON
|
|
574
|
+
fields retain their existing shapes. `whoami` adds optional
|
|
575
|
+
`hosted_trust_domain` and `hosted_trust_domain_source: "profile:<name>"` fields
|
|
576
|
+
when that profile's saved tenant matches the inspected session. These fields
|
|
577
|
+
describe locally saved metadata, not current binding eligibility. Tenant
|
|
578
|
+
overrides never display another tenant's saved domain; session exit codes and
|
|
579
|
+
offline operation are unchanged. Profile show says "not saved in this profile"
|
|
580
|
+
when metadata is absent, without asserting that AAC has no allocation.
|
|
581
|
+
|
|
582
|
+
Existing tenant admins use `aac tenant assign-hosted-domain --profile dev`;
|
|
583
|
+
explicit eligible restoration uses `aac tenant reactivate-hosted-domain --profile dev`.
|
|
584
|
+
Neither command registers another tenant or asks for a DNS name.
|
|
585
|
+
See [the hosted-domain guide](../../docs/b239-hosted-trust-domains.md) for
|
|
586
|
+
custom-domain progression, recovery compatibility and lifecycle limits.
|
|
@@ -83,14 +83,14 @@ aac sso login --profile dev # thereafter: plain login
|
|
|
83
83
|
# Tenant ids are the server-allocated canonical tnt-<uuid> form
|
|
84
84
|
# (B154 PR 5) — captured at registration / shown by `aac profile show`.
|
|
85
85
|
aac tenant list # which tenant ids exist (no 409 probe)
|
|
86
|
-
aac tenant describe --
|
|
86
|
+
aac tenant describe --profile dev # assigned domain, workloads + key METADATA
|
|
87
87
|
aac tenant update --tenant-id tnt-<uuid> --display-name "ACME Manufacturing Inc"
|
|
88
88
|
|
|
89
89
|
# B121/B228 — business-domain control proof and bounded claim lifecycle.
|
|
90
90
|
# Registration can capture up to 16 pending rows with repeatable --tenant-domain.
|
|
91
91
|
# The issue/verify/release/revoke commands
|
|
92
92
|
# require the owning tenant's tenant-admin session and default --tenant-id from
|
|
93
|
-
# the profile;
|
|
93
|
+
# the profile; tenant describe also uses the selected profile by default.
|
|
94
94
|
aac tenant register --display-name "ACME" --contact ops@acme.example \
|
|
95
95
|
--tenant-domain acme.example
|
|
96
96
|
aac tenant issue-domain-challenge --domain acme.example
|
|
@@ -101,7 +101,7 @@ aac tenant verify-domain --domain acme.example
|
|
|
101
101
|
# never performs an implicit binding write.
|
|
102
102
|
aac tenant bind-trust-domain --trust-domain acme.example
|
|
103
103
|
aac tenant list-trust-domains --output table
|
|
104
|
-
aac tenant describe --
|
|
104
|
+
aac tenant describe --profile dev --output table # assigned binding status + TXT
|
|
105
105
|
aac tenant release-domain --domain acme.example --reason "planned transfer"
|
|
106
106
|
aac tenant revoke-domain --domain acme.example --reason "DNS control incident"
|
|
107
107
|
|
|
@@ -263,9 +263,9 @@ aac sso login --flow pkce --no-browser # print the URL, don't launch
|
|
|
263
263
|
# any admin verb tells you to re-run `aac sso login` — there is no
|
|
264
264
|
# refresh token to steal, and no silent re-authentication.
|
|
265
265
|
|
|
266
|
-
# B155 — session
|
|
267
|
-
#
|
|
268
|
-
aac sso whoami --
|
|
266
|
+
# B155/B239 — local session/profile inspection; no server call or JWT parsing.
|
|
267
|
+
# whoami exits 0 = unexpired cache, 3 = expired/none (§14).
|
|
268
|
+
aac sso whoami --profile dev --output table # cached session + saved assigned domain
|
|
269
269
|
aac sso logout --tenant-id tnt-550e8400-e29b-41d4-a716-446655440000
|
|
270
270
|
```
|
|
271
271
|
|
|
@@ -479,8 +479,9 @@ coexistence, Eng Spec §XVII.9):
|
|
|
479
479
|
expiry fails fast locally with the re-login message instead of
|
|
480
480
|
dialing out; the server's own session 401s carry the same
|
|
481
481
|
remediation (the server stays authoritative). `aac sso logout`
|
|
482
|
-
removes the file; `aac sso whoami` reports its tenant + expiry
|
|
483
|
-
|
|
482
|
+
removes the file; `aac sso whoami` reports its tenant + expiry and, when
|
|
483
|
+
available, the matching profile's saved AAC-assigned domain. Both commands
|
|
484
|
+
operate locally; `whoami` does not verify current server-side binding status.
|
|
484
485
|
|
|
485
486
|
## Your tenant_id
|
|
486
487
|
|
|
@@ -531,3 +532,39 @@ ceremony-only, and existing binding history remains B181.
|
|
|
531
532
|
needed. Against a live stack: bring up the joined topology
|
|
532
533
|
(`./bin/run-wedge-a-control-plane-compose.sh --keep-up`) and point the
|
|
533
534
|
flags at localhost.
|
|
535
|
+
|
|
536
|
+
## Assigned tenant domains (B239)
|
|
537
|
+
|
|
538
|
+
Registration automatically saves the server-assigned `hosted_trust_domain` in
|
|
539
|
+
the selected profile. The CLI displays the full name, explains its purpose in
|
|
540
|
+
SPIFFE identities/certificates/trust configuration, and prints the command for
|
|
541
|
+
finding it again. No manual copy is required. With JSON output, this guidance
|
|
542
|
+
goes to stderr and stdout remains one JSON document; table output includes it
|
|
543
|
+
inline. Completed-registration recovery provides the same saved-name guidance
|
|
544
|
+
without changing credential-reissue requirements.
|
|
545
|
+
|
|
546
|
+
```sh
|
|
547
|
+
aac profile show dev --output table # saved locally, offline
|
|
548
|
+
aac tenant describe --profile dev --output table # assigned name + current binding status
|
|
549
|
+
aac tenant list-trust-domains --profile dev --output table # current ACTIVE bindings
|
|
550
|
+
aac tenant list-trust-domains --profile dev --all --output table # includes revoked history
|
|
551
|
+
aac sso whoami --profile dev --output table # cached session + matching saved name
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
`tenant describe` accepts an optional `--tenant-id`; the usual flag → environment
|
|
555
|
+
→ selected profile precedence applies. Its response comes from AAC. Binding
|
|
556
|
+
table output labels AAC-assigned/custom origin, ACTIVE/REVOKED status and
|
|
557
|
+
inherited scope. Registration, describe, profile show and binding-list JSON
|
|
558
|
+
fields retain their existing shapes. `whoami` adds optional
|
|
559
|
+
`hosted_trust_domain` and `hosted_trust_domain_source: "profile:<name>"` fields
|
|
560
|
+
when that profile's saved tenant matches the inspected session. These fields
|
|
561
|
+
describe locally saved metadata, not current binding eligibility. Tenant
|
|
562
|
+
overrides never display another tenant's saved domain; session exit codes and
|
|
563
|
+
offline operation are unchanged. Profile show says "not saved in this profile"
|
|
564
|
+
when metadata is absent, without asserting that AAC has no allocation.
|
|
565
|
+
|
|
566
|
+
Existing tenant admins use `aac tenant assign-hosted-domain --profile dev`;
|
|
567
|
+
explicit eligible restoration uses `aac tenant reactivate-hosted-domain --profile dev`.
|
|
568
|
+
Neither command registers another tenant or asks for a DNS name.
|
|
569
|
+
See [the hosted-domain guide](../../docs/b239-hosted-trust-domains.md) for
|
|
570
|
+
custom-domain progression, recovery compatibility and lifecycle limits.
|
|
@@ -107,6 +107,7 @@ from aac_cli.config import (
|
|
|
107
107
|
resolve_config,
|
|
108
108
|
select_profile,
|
|
109
109
|
session_credentials_path,
|
|
110
|
+
validate_hosted_trust_domain,
|
|
110
111
|
write_api_key,
|
|
111
112
|
write_profile,
|
|
112
113
|
write_session_credential,
|
|
@@ -619,6 +620,45 @@ def _cmd_tenant_register(args: argparse.Namespace) -> int:
|
|
|
619
620
|
return _cmd_tenant_register_locked(args, selection.name)
|
|
620
621
|
|
|
621
622
|
|
|
623
|
+
def _print_hosted_domain_guidance(domain: str, profile: str, *, output: str) -> None:
|
|
624
|
+
"""Keep registration JSON on stdout; explain the saved name to humans."""
|
|
625
|
+
stream = sys.stdout if output == "table" else sys.stderr
|
|
626
|
+
print(
|
|
627
|
+
f"\nAAC-assigned trust domain:\n{domain}\n\n"
|
|
628
|
+
f"Saved automatically in profile [{profile}].\n"
|
|
629
|
+
"Use this domain in your agents' SPIFFE workload identities,\n"
|
|
630
|
+
"certificates, and trust configuration. No tenant DNS setup is required.\n"
|
|
631
|
+
"You can continue using it when you add a custom domain later.\n\n"
|
|
632
|
+
"View your saved domain:\n"
|
|
633
|
+
f" aac profile show {shlex.quote(profile)} --output table",
|
|
634
|
+
file=stream,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _hosted_domain_from_payload(payload: object, tenant_id: str) -> str | None:
|
|
639
|
+
if payload is None:
|
|
640
|
+
return None
|
|
641
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("trust_domain"), str):
|
|
642
|
+
raise CliConfigError("server returned invalid hosted-domain metadata")
|
|
643
|
+
binding = payload.get("binding")
|
|
644
|
+
if (
|
|
645
|
+
not isinstance(binding, dict)
|
|
646
|
+
or binding.get("scope_tenant_id") != tenant_id
|
|
647
|
+
or binding.get("trust_domain") != payload["trust_domain"]
|
|
648
|
+
or binding.get("status") not in ("ACTIVE", "REVOKED")
|
|
649
|
+
or type(binding.get("binding_version")) is not int
|
|
650
|
+
or binding["binding_version"] < 1
|
|
651
|
+
or not isinstance(binding.get("binding_id"), str)
|
|
652
|
+
):
|
|
653
|
+
raise CliConfigError("server returned a hosted binding for a different tenant")
|
|
654
|
+
try:
|
|
655
|
+
if str(uuid.UUID(binding["binding_id"])) != binding["binding_id"]:
|
|
656
|
+
raise ValueError("noncanonical binding id")
|
|
657
|
+
except ValueError as exc:
|
|
658
|
+
raise CliConfigError("server returned invalid hosted binding metadata") from exc
|
|
659
|
+
return validate_hosted_trust_domain(tenant_id, payload["trust_domain"])
|
|
660
|
+
|
|
661
|
+
|
|
622
662
|
def _cmd_tenant_register_locked(args: argparse.Namespace, profile: str) -> int:
|
|
623
663
|
config = _resolve(args)
|
|
624
664
|
normalized_admin_url = normalize_admin_url(config.admin_url)
|
|
@@ -887,7 +927,10 @@ def _cmd_tenant_register_locked(args: argparse.Namespace, profile: str) -> int:
|
|
|
887
927
|
)
|
|
888
928
|
return 3
|
|
889
929
|
try:
|
|
890
|
-
|
|
930
|
+
recovered_domain = response.headers.get("X-AAC-Hosted-Trust-Domain")
|
|
931
|
+
profile_path = bind_profile_for_registration(
|
|
932
|
+
profile, returned_tenant_id, hosted_trust_domain=recovered_domain
|
|
933
|
+
)
|
|
891
934
|
except (CliConfigError, OSError) as exc:
|
|
892
935
|
state = _mark_registration_unknown(state)
|
|
893
936
|
print(
|
|
@@ -896,6 +939,9 @@ def _cmd_tenant_register_locked(args: argparse.Namespace, profile: str) -> int:
|
|
|
896
939
|
file=sys.stderr,
|
|
897
940
|
)
|
|
898
941
|
return 3
|
|
942
|
+
if recovered_domain is not None:
|
|
943
|
+
payload["hosted_trust_domain"] = recovered_domain
|
|
944
|
+
_print_hosted_domain_guidance(recovered_domain, profile, output=args.output)
|
|
899
945
|
try:
|
|
900
946
|
read_api_key(returned_tenant_id)
|
|
901
947
|
except CliConfigError:
|
|
@@ -968,7 +1014,13 @@ def _cmd_tenant_register_locked(args: argparse.Namespace, profile: str) -> int:
|
|
|
968
1014
|
file=sys.stderr,
|
|
969
1015
|
)
|
|
970
1016
|
try:
|
|
971
|
-
profile_path = bind_profile_for_registration(
|
|
1017
|
+
profile_path = bind_profile_for_registration(
|
|
1018
|
+
profile,
|
|
1019
|
+
returned_tenant_id,
|
|
1020
|
+
hosted_trust_domain=_hosted_domain_from_payload(
|
|
1021
|
+
payload.get("hosted_trust_domain"), returned_tenant_id
|
|
1022
|
+
),
|
|
1023
|
+
)
|
|
972
1024
|
except (CliConfigError, OSError) as exc:
|
|
973
1025
|
state = _mark_registration_unknown(state)
|
|
974
1026
|
print(
|
|
@@ -1002,6 +1054,8 @@ def _cmd_tenant_register_locked(args: argparse.Namespace, profile: str) -> int:
|
|
|
1002
1054
|
}
|
|
1003
1055
|
if stored_at is None:
|
|
1004
1056
|
result["api_key"] = api_key
|
|
1057
|
+
if payload.get("hosted_trust_domain") is not None:
|
|
1058
|
+
result["hosted_trust_domain"] = payload["hosted_trust_domain"]["trust_domain"]
|
|
1005
1059
|
if args.output == "table":
|
|
1006
1060
|
print(f"tenant_id : {returned_tenant_id}")
|
|
1007
1061
|
print(f"key_id : {key_id}")
|
|
@@ -1030,6 +1084,10 @@ def _cmd_tenant_register_locked(args: argparse.Namespace, profile: str) -> int:
|
|
|
1030
1084
|
)
|
|
1031
1085
|
else:
|
|
1032
1086
|
print(json.dumps(result, indent=2))
|
|
1087
|
+
if payload.get("hosted_trust_domain") is not None:
|
|
1088
|
+
_print_hosted_domain_guidance(
|
|
1089
|
+
payload["hosted_trust_domain"]["trust_domain"], profile, output=args.output
|
|
1090
|
+
)
|
|
1033
1091
|
if stored_at is not None:
|
|
1034
1092
|
_print_api_key_verification_guidance(
|
|
1035
1093
|
tenant_id=returned_tenant_id,
|
|
@@ -1050,6 +1108,16 @@ def _render_tenant_status_table(body: dict) -> None:
|
|
|
1050
1108
|
value = body.get(field)
|
|
1051
1109
|
print(f"{field:<18}: {value if value is not None else '-'}")
|
|
1052
1110
|
api_key = body.get("api_key") or {}
|
|
1111
|
+
hosted = body.get("hosted_trust_domain")
|
|
1112
|
+
if isinstance(hosted, dict):
|
|
1113
|
+
print(f"AAC-assigned trust domain: {hosted.get('trust_domain', '-')}")
|
|
1114
|
+
binding = hosted.get("binding") or {}
|
|
1115
|
+
print(f"hosted binding status : {binding.get('status', '-')}")
|
|
1116
|
+
print(f"hosted binding version : {binding.get('binding_version', '-')}")
|
|
1117
|
+
if hosted.get("platform_hold"):
|
|
1118
|
+
print("hosted platform hold : yes")
|
|
1119
|
+
elif "hosted_trust_domain" in body:
|
|
1120
|
+
print("AAC-assigned trust domain: not assigned")
|
|
1053
1121
|
print(f"{'api_key.key_id':<18}: {api_key.get('key_id', '-')} (metadata only)")
|
|
1054
1122
|
workloads = body.get("workloads") or []
|
|
1055
1123
|
print(f"{'workloads':<18}: {len(workloads)}")
|
|
@@ -1230,10 +1298,17 @@ def _cmd_tenant_revoke_domain(args: argparse.Namespace) -> int:
|
|
|
1230
1298
|
|
|
1231
1299
|
|
|
1232
1300
|
def _cmd_tenant_describe(args: argparse.Namespace) -> int:
|
|
1233
|
-
|
|
1301
|
+
tenant_id = _resolve(args).tenant_id
|
|
1302
|
+
if tenant_id is None:
|
|
1303
|
+
print(
|
|
1304
|
+
"select a profile bound to the tenant, or provide --tenant-id",
|
|
1305
|
+
file=sys.stderr,
|
|
1306
|
+
)
|
|
1307
|
+
return 3
|
|
1308
|
+
_require_canonical_tenant_id_arg(tenant_id)
|
|
1234
1309
|
# URL-encode the path segment (B130 review M1 lesson): a stray
|
|
1235
1310
|
# '#'/'?' must not truncate the path or inject a query.
|
|
1236
|
-
code, body = _admin_json(args, "GET", f"/v1/tenants/{quote(
|
|
1311
|
+
code, body = _admin_json(args, "GET", f"/v1/tenants/{quote(tenant_id, safe='')}")
|
|
1237
1312
|
if body is None:
|
|
1238
1313
|
return code
|
|
1239
1314
|
if args.output == "table":
|
|
@@ -2217,11 +2292,72 @@ def _print_binding_row(payload: dict) -> None:
|
|
|
2217
2292
|
print(f"scope_tenant_id : {payload['scope_tenant_id']}")
|
|
2218
2293
|
admission = payload.get("admission")
|
|
2219
2294
|
if isinstance(admission, dict):
|
|
2295
|
+
method = admission.get("admission_method")
|
|
2296
|
+
if method == "aac_assigned":
|
|
2297
|
+
print("domain origin : AAC-assigned")
|
|
2298
|
+
elif method in ("pre_b182", "operator_ceremony", "dns_txt_exact", "auth_unconfigured"):
|
|
2299
|
+
print("domain origin : Custom")
|
|
2300
|
+
else:
|
|
2301
|
+
print("domain origin : not reported")
|
|
2220
2302
|
print(f"admission : {admission.get('admission_method', '-')}")
|
|
2221
2303
|
if admission.get("evidence_domain"):
|
|
2222
2304
|
print(f"evidence_domain : {admission['evidence_domain']}")
|
|
2223
2305
|
|
|
2224
2306
|
|
|
2307
|
+
def _cmd_tenant_hosted_domain(args: argparse.Namespace) -> int:
|
|
2308
|
+
profile = select_profile(args.profile).name
|
|
2309
|
+
with profile_registration_lock(profile):
|
|
2310
|
+
config = _resolve(args)
|
|
2311
|
+
tenant_id = config.tenant_id
|
|
2312
|
+
if tenant_id is None:
|
|
2313
|
+
print(
|
|
2314
|
+
"select a profile bound to the existing tenant, or provide --tenant-id",
|
|
2315
|
+
file=sys.stderr,
|
|
2316
|
+
)
|
|
2317
|
+
return 3
|
|
2318
|
+
headers = _binding_headers(args, config)
|
|
2319
|
+
if isinstance(headers, int):
|
|
2320
|
+
return headers
|
|
2321
|
+
suffix = "/reactivation" if args.hosted_reactivate else ""
|
|
2322
|
+
try:
|
|
2323
|
+
response = httpx.post(
|
|
2324
|
+
f"{config.admin_url}/v1/tenants/{tenant_id}/hosted-trust-domain{suffix}",
|
|
2325
|
+
json={},
|
|
2326
|
+
headers=headers,
|
|
2327
|
+
timeout=_REQUEST_TIMEOUT_SECONDS,
|
|
2328
|
+
)
|
|
2329
|
+
except httpx.HTTPError as exc:
|
|
2330
|
+
print(f"hosted-domain request failed: {type(exc).__name__}", file=sys.stderr)
|
|
2331
|
+
return 4
|
|
2332
|
+
if response.status_code not in (200, 201):
|
|
2333
|
+
_print_envelope_with_login_remediation(response, tenant_id)
|
|
2334
|
+
return 1
|
|
2335
|
+
try:
|
|
2336
|
+
payload = response.json()
|
|
2337
|
+
domain = _hosted_domain_from_payload(payload, tenant_id)
|
|
2338
|
+
if domain is None:
|
|
2339
|
+
raise CliConfigError("server returned no hosted domain")
|
|
2340
|
+
if read_profile_section(profile).get("tenant_id") == tenant_id:
|
|
2341
|
+
bind_profile_for_registration(profile, tenant_id, hosted_trust_domain=domain)
|
|
2342
|
+
else:
|
|
2343
|
+
print(
|
|
2344
|
+
"hosted domain returned; selected profile is not bound to this tenant, so its binding was preserved",
|
|
2345
|
+
file=sys.stderr,
|
|
2346
|
+
)
|
|
2347
|
+
except (ValueError, CliConfigError, OSError):
|
|
2348
|
+
print(
|
|
2349
|
+
"the operation returned success but its metadata could not be saved; inspect the selected profile and safely retry the same command",
|
|
2350
|
+
file=sys.stderr,
|
|
2351
|
+
)
|
|
2352
|
+
return 3
|
|
2353
|
+
if args.output == "table":
|
|
2354
|
+
print(f"hosted_domain : {domain}")
|
|
2355
|
+
_print_binding_row(payload["binding"])
|
|
2356
|
+
else:
|
|
2357
|
+
print(json.dumps(payload, indent=2))
|
|
2358
|
+
return 0
|
|
2359
|
+
|
|
2360
|
+
|
|
2225
2361
|
def _cmd_tenant_bind_trust_domain(args: argparse.Namespace) -> int:
|
|
2226
2362
|
"""Create (or idempotently re-affirm) a trust-domain binding
|
|
2227
2363
|
anchored at this tenant. A session caller needs current exact-name
|
|
@@ -2338,7 +2474,10 @@ def _cmd_tenant_list_trust_domains(args: argparse.Namespace) -> int:
|
|
|
2338
2474
|
for item in bindings:
|
|
2339
2475
|
_print_binding_row(item)
|
|
2340
2476
|
if item.get("inherited"):
|
|
2477
|
+
print("scope : inherited from ancestor tenant")
|
|
2341
2478
|
print("inherited : true")
|
|
2479
|
+
else:
|
|
2480
|
+
print("scope : anchored at this tenant")
|
|
2342
2481
|
if item.get("revoked_at"):
|
|
2343
2482
|
print(f"revoked_at : {item['revoked_at']}")
|
|
2344
2483
|
print(f"reason : {item.get('revocation_reason') or ''}")
|
|
@@ -3847,9 +3986,11 @@ def _cmd_sso_replace_idp(args: argparse.Namespace) -> int:
|
|
|
3847
3986
|
|
|
3848
3987
|
|
|
3849
3988
|
def _cmd_sso_whoami(args: argparse.Namespace) -> int:
|
|
3850
|
-
"""B155
|
|
3851
|
-
|
|
3852
|
-
|
|
3989
|
+
"""B155 session facts plus optional B239 domain context, all local.
|
|
3990
|
+
|
|
3991
|
+
The CLI reads the session cache and matching profile, never the JWT or
|
|
3992
|
+
a server. The server stays authoritative for session and binding status.
|
|
3993
|
+
"""
|
|
3853
3994
|
config = _resolve(args)
|
|
3854
3995
|
tenant_id = config.tenant_id
|
|
3855
3996
|
if not tenant_id:
|
|
@@ -3880,26 +4021,40 @@ def _cmd_sso_whoami(args: argparse.Namespace) -> int:
|
|
|
3880
4021
|
expires_at_display = time.strftime(
|
|
3881
4022
|
"%Y-%m-%d %H:%M:%S %Z", time.localtime(credential.expires_at_unix_seconds)
|
|
3882
4023
|
)
|
|
4024
|
+
profile = select_profile(args.profile).name
|
|
4025
|
+
stored = read_profile_section(profile)
|
|
4026
|
+
# Flags/environment may select a different tenant from the saved profile.
|
|
4027
|
+
# Cached domain context must belong to the inspected session's tenant.
|
|
4028
|
+
hosted_domain = (
|
|
4029
|
+
stored.get("hosted_trust_domain")
|
|
4030
|
+
if stored.get("tenant_id") == credential.tenant_id == tenant_id
|
|
4031
|
+
else None
|
|
4032
|
+
)
|
|
4033
|
+
payload = {
|
|
4034
|
+
"tenant_id": credential.tenant_id,
|
|
4035
|
+
"audience": credential.audience,
|
|
4036
|
+
"expires_at_unix_seconds": credential.expires_at_unix_seconds,
|
|
4037
|
+
"expired": expired,
|
|
4038
|
+
"path": str(session_credentials_path(tenant_id)),
|
|
4039
|
+
}
|
|
4040
|
+
if hosted_domain is not None:
|
|
4041
|
+
payload["hosted_trust_domain"] = hosted_domain
|
|
4042
|
+
payload["hosted_trust_domain_source"] = f"profile:{profile}"
|
|
3883
4043
|
if args.output == "table":
|
|
3884
4044
|
print(f"tenant_id : {credential.tenant_id}")
|
|
3885
4045
|
print(f"audience : {credential.audience}")
|
|
3886
4046
|
print(f"expires_at : {expires_at_display}{' (EXPIRED)' if expired else ''}")
|
|
3887
4047
|
print(f"session : {session_credentials_path(tenant_id)}")
|
|
4048
|
+
if hosted_domain is not None:
|
|
4049
|
+
print(f"AAC-assigned trust domain (saved in profile [{profile}]):\n{hosted_domain}")
|
|
4050
|
+
print(
|
|
4051
|
+
"Use `aac tenant list-trust-domains` with the same profile and endpoint "
|
|
4052
|
+
"for current server-side bindings and status."
|
|
4053
|
+
)
|
|
3888
4054
|
if expired:
|
|
3889
4055
|
print("\nSession expired — run `aac sso login` again.")
|
|
3890
4056
|
else:
|
|
3891
|
-
print(
|
|
3892
|
-
json.dumps(
|
|
3893
|
-
{
|
|
3894
|
-
"tenant_id": credential.tenant_id,
|
|
3895
|
-
"audience": credential.audience,
|
|
3896
|
-
"expires_at_unix_seconds": credential.expires_at_unix_seconds,
|
|
3897
|
-
"expired": expired,
|
|
3898
|
-
"path": str(session_credentials_path(tenant_id)),
|
|
3899
|
-
},
|
|
3900
|
-
indent=2,
|
|
3901
|
-
)
|
|
3902
|
-
)
|
|
4057
|
+
print(json.dumps(payload, indent=2))
|
|
3903
4058
|
return 3 if expired else 0 # review M5: local state → 3, not 1 (§14)
|
|
3904
4059
|
|
|
3905
4060
|
|
|
@@ -4378,10 +4533,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
4378
4533
|
_add_common_flags(tenant_describe)
|
|
4379
4534
|
tenant_describe.add_argument(
|
|
4380
4535
|
"--tenant-id",
|
|
4381
|
-
|
|
4536
|
+
default=None,
|
|
4382
4537
|
help=(
|
|
4383
|
-
"The tenant to describe (
|
|
4384
|
-
"
|
|
4538
|
+
"The tenant to describe (defaults to the selected profile/environment identity; "
|
|
4539
|
+
"an explicit canonical tenant ID overrides it)."
|
|
4385
4540
|
),
|
|
4386
4541
|
)
|
|
4387
4542
|
tenant_describe.set_defaults(handler=_cmd_tenant_describe)
|
|
@@ -4765,6 +4920,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
4765
4920
|
)
|
|
4766
4921
|
deactivate_workload.set_defaults(handler=_cmd_tenant_deactivate_workload)
|
|
4767
4922
|
|
|
4923
|
+
for verb, reactivation in (("assign-hosted-domain", False), ("reactivate-hosted-domain", True)):
|
|
4924
|
+
hosted = tenant_verbs.add_parser(
|
|
4925
|
+
verb,
|
|
4926
|
+
help=(
|
|
4927
|
+
"Reactivate the existing assigned binding without changing tenant identity."
|
|
4928
|
+
if reactivation
|
|
4929
|
+
else "Assign a hosted domain to an existing tenant; repeated calls reuse it."
|
|
4930
|
+
),
|
|
4931
|
+
)
|
|
4932
|
+
_add_common_flags(hosted)
|
|
4933
|
+
hosted.add_argument(
|
|
4934
|
+
"--tenant-id", default=None, help="Tenant (defaults to the selected profile)."
|
|
4935
|
+
)
|
|
4936
|
+
hosted.add_argument(
|
|
4937
|
+
"--bootstrap-token",
|
|
4938
|
+
default=None,
|
|
4939
|
+
help="Explicit platform ceremony; otherwise use the tenant-admin session.",
|
|
4940
|
+
)
|
|
4941
|
+
hosted.set_defaults(handler=_cmd_tenant_hosted_domain, hosted_reactivate=reactivation)
|
|
4942
|
+
|
|
4768
4943
|
bind_domain = tenant_verbs.add_parser(
|
|
4769
4944
|
"bind-trust-domain",
|
|
4770
4945
|
help="Bind a SPIFFE trust domain using current exact-name evidence or ceremony.",
|
|
@@ -5306,12 +5481,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5306
5481
|
|
|
5307
5482
|
whoami = sso_verbs.add_parser(
|
|
5308
5483
|
"whoami",
|
|
5309
|
-
help="Show
|
|
5484
|
+
help="Show cached session identity/expiry and the profile's saved AAC-assigned domain.",
|
|
5310
5485
|
description=(
|
|
5311
|
-
"Reads
|
|
5486
|
+
"Reads local session/profile files only — the CLI never parses "
|
|
5312
5487
|
"the token itself (the server stays authoritative). Exit 0 "
|
|
5313
|
-
"=
|
|
5314
|
-
"state, Spec §14)."
|
|
5488
|
+
"= an unexpired cached session; exit 3 = expired or none (local cache "
|
|
5489
|
+
"state, Spec §14). The assigned domain, when present, comes from the matching "
|
|
5490
|
+
"local profile; use tenant list-trust-domains for current server-side bindings."
|
|
5315
5491
|
),
|
|
5316
5492
|
)
|
|
5317
5493
|
_add_common_flags(whoami)
|
|
@@ -100,6 +100,7 @@ __all__ = [
|
|
|
100
100
|
"write_api_key",
|
|
101
101
|
"write_profile",
|
|
102
102
|
"write_session_credential",
|
|
103
|
+
"validate_hosted_trust_domain",
|
|
103
104
|
]
|
|
104
105
|
|
|
105
106
|
_DEFAULT_ADMIN_URL = "http://127.0.0.1:8000"
|
|
@@ -449,6 +450,28 @@ def _enforce_guardrails(parser: configparser.ConfigParser, config_path: Path) ->
|
|
|
449
450
|
f"`aac tenant register` or `aac sso login` "
|
|
450
451
|
f"(Spec §5.4, §8.6)"
|
|
451
452
|
)
|
|
453
|
+
if key == "hosted_trust_domain":
|
|
454
|
+
validate_hosted_trust_domain(parser[section].get("tenant_id"), value)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def validate_hosted_trust_domain(tenant_id: str | None, value: str) -> str:
|
|
458
|
+
"""Validate system-managed metadata without importing the control plane."""
|
|
459
|
+
if (
|
|
460
|
+
tenant_id is None
|
|
461
|
+
or CANONICAL_TENANT_ID_PATTERN.fullmatch(tenant_id) is None
|
|
462
|
+
or not isinstance(value, str)
|
|
463
|
+
or len(value) > 255
|
|
464
|
+
or not value.startswith(tenant_id + ".tenants.")
|
|
465
|
+
or any(
|
|
466
|
+
re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) is None
|
|
467
|
+
for label in value.split(".")
|
|
468
|
+
)
|
|
469
|
+
):
|
|
470
|
+
raise CliConfigError(
|
|
471
|
+
"hosted_trust_domain must be canonical metadata for the bound tenant; "
|
|
472
|
+
"remove an invalid local value and retrieve the allocation through the authenticated tenant commands"
|
|
473
|
+
)
|
|
474
|
+
return value
|
|
452
475
|
|
|
453
476
|
|
|
454
477
|
def _load_validated_config_unlocked() -> configparser.ConfigParser:
|
|
@@ -634,7 +657,9 @@ def write_profile(profile: str, settings: dict[str, str]) -> Path:
|
|
|
634
657
|
return _write_profile_locked(profile, settings)
|
|
635
658
|
|
|
636
659
|
|
|
637
|
-
def bind_profile_for_registration(
|
|
660
|
+
def bind_profile_for_registration(
|
|
661
|
+
profile: str, tenant_id: str, *, hosted_trust_domain: str | None = None
|
|
662
|
+
) -> Path:
|
|
638
663
|
"""Bind a registration result without overwriting a concurrent binding.
|
|
639
664
|
|
|
640
665
|
Registration is fail-closed while verified login is last-wins. The selected
|
|
@@ -650,7 +675,18 @@ def bind_profile_for_registration(profile: str, tenant_id: str) -> Path:
|
|
|
650
675
|
f"profile [{profile}] became bound to another tenant while "
|
|
651
676
|
"registration was in progress; refusing to replace that binding"
|
|
652
677
|
)
|
|
653
|
-
|
|
678
|
+
settings = {"tenant_id": tenant_id}
|
|
679
|
+
if hosted_trust_domain is not None:
|
|
680
|
+
validate_hosted_trust_domain(tenant_id, hosted_trust_domain)
|
|
681
|
+
existing_domain = (
|
|
682
|
+
parser[profile].get("hosted_trust_domain") if parser.has_section(profile) else None
|
|
683
|
+
)
|
|
684
|
+
if existing_domain is not None and existing_domain != hosted_trust_domain:
|
|
685
|
+
raise CliConfigError(
|
|
686
|
+
"the returned hosted domain differs from the stored permanent allocation; refusing to overwrite it"
|
|
687
|
+
)
|
|
688
|
+
settings["hosted_trust_domain"] = hosted_trust_domain
|
|
689
|
+
return _write_profile_locked(profile, settings)
|
|
654
690
|
|
|
655
691
|
|
|
656
692
|
def _write_profile_locked(profile: str, settings: dict[str, str]) -> Path:
|
|
@@ -675,6 +711,11 @@ def _write_profile_locked(profile: str, settings: dict[str, str]) -> Path:
|
|
|
675
711
|
f"profiles besides the reserved [main]) (Spec §5.2)"
|
|
676
712
|
)
|
|
677
713
|
parser.add_section(profile)
|
|
714
|
+
prior_tenant_id = parser[profile].get("tenant_id")
|
|
715
|
+
if "tenant_id" in settings and settings["tenant_id"] != prior_tenant_id:
|
|
716
|
+
# Verified login may intentionally rebind a profile; metadata of the
|
|
717
|
+
# previous tenant must not follow that new association.
|
|
718
|
+
parser[profile].pop("hosted_trust_domain", None)
|
|
678
719
|
for key, value in settings.items():
|
|
679
720
|
if len(key) > MAX_KEY_CHARS:
|
|
680
721
|
raise CliConfigError(
|
|
@@ -706,6 +747,8 @@ def _write_profile_locked(profile: str, settings: dict[str, str]) -> Path:
|
|
|
706
747
|
f"canonical tnt-<lowercase UUIDv4> form (value not shown) — "
|
|
707
748
|
f"tenant ids are server-allocated since B154 PR 5 (Spec §5.4)"
|
|
708
749
|
)
|
|
750
|
+
if "hosted_trust_domain" in parser[profile]:
|
|
751
|
+
validate_hosted_trust_domain(result_tenant_id, parser[profile]["hosted_trust_domain"])
|
|
709
752
|
_write_parser_atomically(parser, config_path)
|
|
710
753
|
return config_path
|
|
711
754
|
|