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.
Files changed (32) hide show
  1. {aac_cli-0.1.2 → aac_cli-0.1.3}/PKG-INFO +46 -9
  2. {aac_cli-0.1.2 → aac_cli-0.1.3}/README.md +45 -8
  3. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/__init__.py +1 -1
  4. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/cli.py +202 -26
  5. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/config.py +45 -2
  6. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/profiles.py +12 -2
  7. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli.egg-info/PKG-INFO +46 -9
  8. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli.egg-info/SOURCES.txt +1 -0
  9. {aac_cli-0.1.2 → aac_cli-0.1.3}/pyproject.toml +1 -1
  10. aac_cli-0.1.3/tests/test_hosted_domains_cli.py +533 -0
  11. {aac_cli-0.1.2 → aac_cli-0.1.3}/LICENSE +0 -0
  12. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/__main__.py +0 -0
  13. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/admin_key_pem.py +0 -0
  14. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/api_key_rotation_state.py +0 -0
  15. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/idp_recovery.py +0 -0
  16. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/registration_state.py +0 -0
  17. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli/sso_login.py +0 -0
  18. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli.egg-info/dependency_links.txt +0 -0
  19. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli.egg-info/entry_points.txt +0 -0
  20. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli.egg-info/requires.txt +0 -0
  21. {aac_cli-0.1.2 → aac_cli-0.1.3}/aac_cli.egg-info/top_level.txt +0 -0
  22. {aac_cli-0.1.2 → aac_cli-0.1.3}/setup.cfg +0 -0
  23. {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_aac_cli.py +0 -0
  24. {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_admin_key_pem.py +0 -0
  25. {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_api_key_reissue_cli.py +0 -0
  26. {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_api_key_rotation_cli.py +0 -0
  27. {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_api_key_rotation_state.py +0 -0
  28. {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_idp_recovery_cli.py +0 -0
  29. {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_profile_cli.py +0 -0
  30. {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_registration_recovery_cli.py +0 -0
  31. {aac_cli-0.1.2 → aac_cli-0.1.3}/tests/test_registration_state.py +0 -0
  32. {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.2
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 --tenant-id tnt-<uuid> # workloads + key METADATA
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; the established tenant describe command keeps its explicit id.
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 --tenant-id tnt-<uuid> --output table # status + TXT
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 niceties (cache-file operations; the CLI never
283
- # parses the token itself). whoami exits 0 = live, 3 = expired/none (§14).
284
- aac sso whoami --tenant-id tnt-550e8400-e29b-41d4-a716-446655440000
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
- (B155 — both operate on the cache file only).
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 --tenant-id tnt-<uuid> # workloads + key METADATA
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; the established tenant describe command keeps its explicit id.
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 --tenant-id tnt-<uuid> --output table # status + TXT
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 niceties (cache-file operations; the CLI never
267
- # parses the token itself). whoami exits 0 = live, 3 = expired/none (§14).
268
- aac sso whoami --tenant-id tnt-550e8400-e29b-41d4-a716-446655440000
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
- (B155 — both operate on the cache file only).
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.
@@ -10,4 +10,4 @@ entrypoint (`aac_cli.cli:entrypoint`).
10
10
  # test in bin/tests/test_cli_version_parity.py. A literal keeps the
11
11
  # shipped code free of import-time metadata lookups (and their
12
12
  # not-installed failure mode).
13
- __version__ = "0.1.2"
13
+ __version__ = "0.1.3"
@@ -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
- profile_path = bind_profile_for_registration(profile, returned_tenant_id)
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(profile, returned_tenant_id)
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
- _require_canonical_tenant_id_arg(args.tenant_id)
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(args.tenant_id, safe='')}")
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 (B142 rider): print the cached session's identity facts —
3851
- FROM THE CACHE FILE ONLY (B141 Decision 2: the CLI never parses the
3852
- JWT; the guard stays authoritative when clocks disagree)."""
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
- required=True,
4536
+ default=None,
4382
4537
  help=(
4383
- "The tenant to describe (canonical federation identifier, "
4384
- "e.g. tnt-550e8400-e29b-41d4-a716-446655440000)."
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 the cached AAC session's tenant + expiry (B155).",
5484
+ help="Show cached session identity/expiry and the profile's saved AAC-assigned domain.",
5310
5485
  description=(
5311
- "Reads the session CACHE FILE only — the CLI never parses "
5486
+ "Reads local session/profile files only — the CLI never parses "
5312
5487
  "the token itself (the server stays authoritative). Exit 0 "
5313
- "= a live session; exit 3 = expired or none (local cache "
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(profile: str, tenant_id: str) -> Path:
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
- return _write_profile_locked(profile, {"tenant_id": tenant_id})
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