diff --git a/.github/workflows/vscode-release.yml b/.github/workflows/vscode-release.yml index 5229b6740..a7d8e343d 100644 --- a/.github/workflows/vscode-release.yml +++ b/.github/workflows/vscode-release.yml @@ -140,6 +140,19 @@ jobs: client-id: ${{ vars.VSCE_AZURE_CLIENT_ID }} tenant-id: ${{ vars.VSCE_AZURE_TENANT_ID }} allow-no-subscriptions: true + - name: Confirm the authenticated publisher profile before uploading + if: matrix.registry == 'visual-studio-marketplace' + env: + VSCE_AZURE_CLIENT_ID: ${{ vars.VSCE_AZURE_CLIENT_ID }} + VSCE_AZURE_TENANT_ID: ${{ vars.VSCE_AZURE_TENANT_ID }} + VSCE_MARKETPLACE_PROFILE_ID: ${{ vars.VSCE_MARKETPLACE_PROFILE_ID }} + run: python3 scripts/marketplace_identity.py --receipt marketplace-identity.json + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() && matrix.registry == 'visual-studio-marketplace' + with: + name: vscode-marketplace-identity-${{ github.sha }}-${{ github.run_attempt }} + path: vs-code-terraphim-it/marketplace-identity.json + if-no-files-found: warn # A retry may encounter an already published exact version. Readback is # the mandatory authority; any absent or different payload fails the job. - name: Publish exact VSIX to Visual Studio Marketplace diff --git a/vs-code-terraphim-it/CHANGELOG.md b/vs-code-terraphim-it/CHANGELOG.md index dacb14c58..f3223d926 100644 --- a/vs-code-terraphim-it/CHANGELOG.md +++ b/vs-code-terraphim-it/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.1.2 (preview) + +- Verify the federated service principal and its Azure DevOps profile before Marketplace publication; retain non-secret identity evidence and refuse an unconfirmed profile. + ## 0.1.1 (preview) - Bundle the verified editor v0.1.0-rc.2 release built with public Terraphim crate dependencies. diff --git a/vs-code-terraphim-it/README-alternative-control.md b/vs-code-terraphim-it/README-alternative-control.md index 7ea698011..ad535f003 100644 --- a/vs-code-terraphim-it/README-alternative-control.md +++ b/vs-code-terraphim-it/README-alternative-control.md @@ -21,4 +21,32 @@ publisher, protected environment and federated identity are ready. Enabled targets remain `PENDING_PUBLICATION` until the separate registry readback passes. These gates do not create access, weaken tag policies or provide a PAT fallback. +Microsoft publication also requires `VSCE_MARKETPLACE_PROFILE_ID` in the existing +`visual-studio-marketplace` environment. After federated login, CI verifies the +service principal client and tenant, then queries its own Azure DevOps profile. +The profile ID is distinct from the Entra application and enterprise object IDs. +An empty or different expected profile ID stops the job before upload and retains +only non-secret metadata in `vscode-marketplace-identity--`. +That receipt proves identity admission, not publisher membership or publication. + +For initial setup, enable the Microsoft readiness gate only after the existing +publisher and its federation are ready. Leave the profile ID unset for the first +identity read. Use the returned profile ID to add that principal as **Contributor** +to `applied-knowledge-systems`; verify the assignment, then configure the expected +profile ID. Rerun only that release's `marketplace-plan` job and its dependent +marketplace job to use the already qualified package. Verify the final registry +receipt against every extension payload file. Do not rerun package qualification +or dispatch the same version afresh: a rebuilt VSIX can have different ZIP +timestamps, and the immutable GitHub publisher will refuse replacement bytes. +The original source tag, package artifact and three native receipts must remain +available; select retained receipts by artifact ID and digest, not name alone. + +Version 0.1.2 introduces this CI-only identity step because the immutable 0.1.1 +tag cannot be changed. It retains the same released editor inputs and runtime +features. Real Azure identity retrieval remains an integration gate until the +configured principal executes it; local payload fixtures do not qualify login. +This step assigns no Azure resource roles or organization membership and creates +no long-lived secrets. See Microsoft's documented +[principal profile lookup](https://learn.microsoft.com/en-us/azure/devops/extend/publish/command-line?view=azure-devops#publish-with-a-microsoft-entra-token). + Acceptance reports distinguish actual saved files, engine tests and visual UI checks. The original 41 visual requirements are not all accepted. This is a scoped preview; stock Zed visual limitations and the separate Zed fork remain outside this VSIX's claims. diff --git a/vs-code-terraphim-it/package-lock.json b/vs-code-terraphim-it/package-lock.json index f3380dfa5..0dca61e7d 100644 --- a/vs-code-terraphim-it/package-lock.json +++ b/vs-code-terraphim-it/package-lock.json @@ -1,12 +1,12 @@ { "name": "terraphim-it", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "terraphim-it", - "version": "0.1.1", + "version": "0.1.2", "license": "Apache-2.0", "dependencies": { "@tomic/cli": "0.40.0", diff --git a/vs-code-terraphim-it/package.json b/vs-code-terraphim-it/package.json index e7c98bae6..c6faa7ec0 100644 --- a/vs-code-terraphim-it/package.json +++ b/vs-code-terraphim-it/package.json @@ -2,7 +2,7 @@ "name": "terraphim-it", "displayName": "Terraphim It", "description": "Intelligent Markdown editor with alternatives, knowledge-graph synonyms and role-based suggestions", - "version": "0.1.1", + "version": "0.1.2", "publisher": "applied-knowledge-systems", "private": true, "license": "Apache-2.0", diff --git a/vs-code-terraphim-it/scripts/marketplace_identity.py b/vs-code-terraphim-it/scripts/marketplace_identity.py new file mode 100644 index 000000000..9be07440f --- /dev/null +++ b/vs-code-terraphim-it/scripts/marketplace_identity.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Read the federated publisher's identity; refuse an unconfirmed profile. + +Only fixed Azure CLI metadata queries are executed. Tokens, full profiles and +CLI error text are never written to stdout, stderr or the retained receipt. +""" +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import uuid + +from verify_vsix import check_tag + +ROOT = Path(__file__).resolve().parents[1] +RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798" +PROFILE_URL = "https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.1" +ACCOUNT_COMMAND = ["az", "account", "show", "--query", + "{tenantId:tenantId,userName:user.name,userType:user.type}", + "--output", "json", "--only-show-errors"] +PROFILE_COMMAND = ["az", "rest", "--method", "get", "--url", PROFILE_URL, + "--resource", RESOURCE, "--query", "{id:id}", + "--output", "json", "--only-show-errors"] + + +class Refusal(ValueError): + """A fixed public error code, never an external response or command output.""" + + +def identifier(value): + if not isinstance(value, str) or re.fullmatch( + r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", value) is None: + raise Refusal("INVALID_IDENTITY_IDENTIFIER") + result = uuid.UUID(value) + if result.int == 0: + raise Refusal("INVALID_IDENTITY_IDENTIFIER") + return str(result) + + +def configuration(environment, version): + """Admit only this repository's exact release tag and explicit identities.""" + if (environment.get("GITHUB_REPOSITORY") != "terraphim/terraphim-ai" + or environment.get("GITHUB_REF_TYPE") != "tag" + or re.fullmatch(r"[0-9a-f]{40}", environment.get("GITHUB_SHA", "")) is None): + raise Refusal("INVALID_RELEASE_CONTEXT") + try: + check_tag(environment.get("GITHUB_REF_NAME", ""), version) + except ValueError: + raise Refusal("INVALID_RELEASE_CONTEXT") from None + client = identifier(environment.get("VSCE_AZURE_CLIENT_ID")) + tenant = identifier(environment.get("VSCE_AZURE_TENANT_ID")) + expected = environment.get("VSCE_MARKETPLACE_PROFILE_ID", "") + return client, tenant, identifier(expected) if expected else None + + +def parse_metadata(raw): + """Reject ambiguous or oversized metadata without echoing any input.""" + def pairs(items): + result = {} + for key, value in items: + if key in result: + raise Refusal("INVALID_METADATA") + result[key] = value + return result + + if not isinstance(raw, bytes) or len(raw) > 65536: + raise Refusal("INVALID_METADATA") + try: + value = json.loads(raw, object_pairs_hook=pairs) + except (ValueError, UnicodeError): + raise Refusal("INVALID_METADATA") from None + if not isinstance(value, dict): + raise Refusal("INVALID_METADATA") + return value + + +def run_metadata(command, timeout=60): + """Capture a real child process privately and expose only validated JSON.""" + try: + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=timeout, check=False) + except subprocess.TimeoutExpired: + raise Refusal("IDENTITY_QUERY_TIMEOUT") from None + except OSError: + raise Refusal("IDENTITY_QUERY_UNAVAILABLE") from None + if result.returncode: + raise Refusal("IDENTITY_QUERY_FAILED") + return parse_metadata(result.stdout) + + +def verify_account(account, client, tenant): + if (not isinstance(account, dict) or set(account) != {"tenantId", "userName", "userType"} + or account["userType"] != "servicePrincipal"): + raise Refusal("UNEXPECTED_ACCOUNT_TYPE") + if identifier(account["tenantId"]) != tenant or identifier(account["userName"]) != client: + raise Refusal("AUTHENTICATED_IDENTITY_MISMATCH") + + +def profile_id(profile): + if not isinstance(profile, dict) or set(profile) != {"id"}: + raise Refusal("INVALID_PROFILE_METADATA") + return identifier(profile["id"]) + + +def confirm_profile(actual, expected): + if expected is None: + raise Refusal("PUBLISHER_MEMBERSHIP_CONFIRMATION_REQUIRED") + if actual != expected: + raise Refusal("PUBLISHER_PROFILE_MISMATCH") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--receipt", type=Path, required=True) + args = parser.parse_args() + result = {"schema": 1, "status": "NOT_READY", + "scope": "Identity admission only; publisher membership and publication require separate verification"} + try: + # Retain earlier evidence, including symlinks to absent destinations. + with args.receipt.open("x", encoding="utf-8") as output: + try: + version = json.loads((ROOT / "package.json").read_text())["version"] + client, tenant, expected = configuration(os.environ, version) + verify_account(run_metadata(ACCOUNT_COMMAND), client, tenant) + actual = profile_id(run_metadata(PROFILE_COMMAND)) + result.update(clientId=client, tenantId=tenant, profileId=actual, + sourceCommit=os.environ["GITHUB_SHA"], tag=os.environ["GITHUB_REF_NAME"]) + confirm_profile(actual, expected) + result["status"] = "IDENTITY_VERIFIED_READY" + except Refusal as error: + result["errorCode"] = str(error) + except (OSError, ValueError, KeyError, TypeError): + result["errorCode"] = "INVALID_LOCAL_CONFIGURATION" + output.write(json.dumps(result, indent=2) + "\n") + except OSError: + print("marketplace_identity: RECEIPT_UNAVAILABLE", file=sys.stderr) + return 1 + if result["status"] != "IDENTITY_VERIFIED_READY": + print("marketplace_identity: " + result["errorCode"], file=sys.stderr) + return 1 + print("marketplace_identity: IDENTITY_VERIFIED_READY (publication not yet verified)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vs-code-terraphim-it/tests/marketplace_identity_test.py b/vs-code-terraphim-it/tests/marketplace_identity_test.py new file mode 100644 index 000000000..053cb2277 --- /dev/null +++ b/vs-code-terraphim-it/tests/marketplace_identity_test.py @@ -0,0 +1,148 @@ +"""Pure identity fixtures and real local child processes; no cloud authentication.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import marketplace_identity as identity + +CLIENT = "11111111-2222-3333-4444-555555555555" +TENANT = "22222222-3333-4444-5555-666666666666" +PROFILE = "33333333-4444-5555-6666-777777777777" +OTHER = "44444444-5555-6666-7777-888888888888" +CANARY = "PRIVATE_OUTPUT_MUST_NOT_ESCAPE" + + +class MarketplaceIdentityTests(unittest.TestCase): + def environment(self): + version = json.loads((ROOT / "package.json").read_text())["version"] + return {"GITHUB_REPOSITORY": "terraphim/terraphim-ai", "GITHUB_REF_TYPE": "tag", + "GITHUB_REF_NAME": "terraphim_vscode-v" + version, "GITHUB_SHA": "a" * 40, + "VSCE_AZURE_CLIENT_ID": CLIENT, "VSCE_AZURE_TENANT_ID": TENANT} + + def test_only_exact_release_context_and_well_formed_configuration_admitted(self): + env = self.environment() + version = json.loads((ROOT / "package.json").read_text())["version"] + self.assertEqual(identity.configuration(env, version), (CLIENT, TENANT, None)) + for key, value in (("GITHUB_REPOSITORY", "elsewhere/repo"), ("GITHUB_REF_TYPE", "branch"), + ("GITHUB_REF_NAME", "main"), ("GITHUB_SHA", "b" * 39), + ("VSCE_AZURE_CLIENT_ID", ""), ("VSCE_AZURE_TENANT_ID", CANARY), + ("VSCE_MARKETPLACE_PROFILE_ID", " ")): + with self.subTest(key=key), self.assertRaises(identity.Refusal) as error: + identity.configuration({**env, key: value}, version) + self.assertNotIn(CANARY, str(error.exception)) + + def test_identifier_refuses_null_zero_whitespace_and_control_characters(self): + for value in (None, [], {}, 3, "", "0" * 32, "00000000-0000-0000-0000-000000000000", + " " + CLIENT, CLIENT + "\n", CLIENT + "?token=" + CANARY): + with self.subTest(value=type(value).__name__), self.assertRaises(identity.Refusal): + identity.identifier(value) + self.assertEqual(identity.identifier("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"), + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + + def test_pure_account_fixture_must_match_service_principal_tenant_and_client(self): + account = {"tenantId": TENANT, "userName": CLIENT, "userType": "servicePrincipal"} + identity.verify_account(account, CLIENT, TENANT) + for changed in (None, [], {}, {**account, "userType": "user"}, + {**account, "userName": OTHER}, {**account, "tenantId": OTHER}, + {**account, "userName": None}, {**account, "accessToken": CANARY}): + with self.subTest(changed=type(changed).__name__), self.assertRaises(identity.Refusal) as error: + identity.verify_account(changed, CLIENT, TENANT) + self.assertNotIn(CANARY, str(error.exception)) + + def test_profile_fixture_is_not_ready_without_explicit_matching_expected_id(self): + actual = identity.profile_id({"id": PROFILE}) + self.assertEqual(actual, PROFILE) + with self.assertRaisesRegex(identity.Refusal, "MEMBERSHIP_CONFIRMATION_REQUIRED"): + identity.confirm_profile(actual, None) + with self.assertRaisesRegex(identity.Refusal, "PROFILE_MISMATCH"): + identity.confirm_profile(actual, OTHER) + identity.confirm_profile(actual, PROFILE) + for value in (None, [], {}, {"id": None}, {"id": PROFILE, "token": CANARY}): + with self.subTest(value=type(value).__name__), self.assertRaises(identity.Refusal): + identity.profile_id(value) + + def test_metadata_parser_refuses_duplicate_keys_types_and_oversized_output(self): + for data in (b'{"id":"first","id":"second"}', b'[]', b'null', b'not-json', + b'\xff', b' ' * 65537, CANARY): + with self.subTest(dataType=type(data).__name__), self.assertRaises(identity.Refusal) as error: + identity.parse_metadata(data) + self.assertEqual(str(error.exception), "INVALID_METADATA") + + def test_real_child_process_json_transport_does_not_expose_stderr(self): + command = [sys.executable, "-c", "import sys; print('{\"transport\":true}'); " + "print('" + CANARY + "',file=sys.stderr)"] + self.assertEqual(identity.run_metadata(command), {"transport": True}) + + def test_real_child_process_failures_timeout_and_invalid_json_are_sanitized(self): + cases = [([sys.executable, "-c", "import sys; print('" + CANARY + "'); " + "print('" + CANARY + "',file=sys.stderr); sys.exit(7)"], 5, "IDENTITY_QUERY_FAILED"), + ([sys.executable, "-c", "import time; time.sleep(5)"], .02, "IDENTITY_QUERY_TIMEOUT"), + ([sys.executable, "-c", "print('" + CANARY + "')"], 5, "INVALID_METADATA"), + (["/nonexistent/metadata-reader"], 5, "IDENTITY_QUERY_UNAVAILABLE")] + for command, timeout, expected in cases: + with self.subTest(expected=expected), self.assertRaises(identity.Refusal) as error: + identity.run_metadata(command, timeout) + self.assertEqual(str(error.exception), expected) + + def test_invalid_configuration_cli_writes_only_fixed_failure_without_azure(self): + with tempfile.TemporaryDirectory() as directory: + receipt = Path(directory) / "identity.json" + result = subprocess.run([sys.executable, str(ROOT / "scripts/marketplace_identity.py"), + "--receipt", str(receipt)], capture_output=True, text=True, timeout=10, + env={**os.environ, **self.environment(), "VSCE_AZURE_CLIENT_ID": CANARY}) + self.assertEqual(result.returncode, 1) + report = json.loads(receipt.read_text()) + self.assertEqual(report["status"], "NOT_READY") + self.assertEqual(report["errorCode"], "INVALID_IDENTITY_IDENTIFIER") + self.assertNotIn(CANARY, result.stdout + result.stderr + receipt.read_text()) + self.assertNotIn("profileId", report) + + def test_existing_and_symlink_receipts_are_not_overwritten(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + old = root / "old.json"; old.write_text("original receipt") + link = root / "link.json"; link.symlink_to(root / "absent.json") + for receipt in (old, link): + result = subprocess.run([sys.executable, str(ROOT / "scripts/marketplace_identity.py"), + "--receipt", str(receipt)], capture_output=True, text=True, timeout=10, + env={**os.environ, **self.environment()}) + self.assertEqual(result.returncode, 1) + self.assertIn("RECEIPT_UNAVAILABLE", result.stderr) + self.assertEqual(old.read_text(), "original receipt") + self.assertTrue(link.is_symlink()) + self.assertFalse((root / "absent.json").exists()) + + def test_fixed_commands_request_only_expected_identity_metadata(self): + self.assertEqual(identity.ACCOUNT_COMMAND[:3], ["az", "account", "show"]) + self.assertEqual(identity.PROFILE_COMMAND[:4], ["az", "rest", "--method", "get"]) + self.assertIn(identity.PROFILE_URL, identity.PROFILE_COMMAND) + self.assertIn("{id:id}", identity.PROFILE_COMMAND) + self.assertIn("499b84ac-1321-427f-aa17-267ca6975798", identity.PROFILE_COMMAND) + for command in (identity.ACCOUNT_COMMAND, identity.PROFILE_COMMAND): + self.assertIn("--only-show-errors", command) + for forbidden in ("get-access-token", "--debug", "--verbose", "--headers", "--password"): + self.assertNotIn(forbidden, command) + + def test_workflow_gate_runs_after_login_before_publish_and_retains_separate_receipt(self): + workflow = (ROOT.parent / ".github/workflows/vscode-release.yml").read_text() + login = workflow.index("uses: azure/login@") + gate = workflow.index("run: python3 scripts/marketplace_identity.py") + publish = workflow.index("run: npm exec -- vsce publish") + self.assertLess(login, gate) + self.assertLess(gate, publish) + block = workflow[workflow.rfind(" - name:", 0, login):workflow.rfind(" - name:", 0, publish)] + self.assertNotIn("continue-on-error: true", block) + self.assertIn("vars.VSCE_MARKETPLACE_PROFILE_ID", block) + self.assertIn("path: vs-code-terraphim-it/marketplace-identity.json", workflow) + self.assertIn("vscode-marketplace-identity-${{ github.sha }}-${{ github.run_attempt }}", workflow) + self.assertNotIn("VSCE_PAT", workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/vs-code-terraphim-it/tests/marketplace_plan_test.py b/vs-code-terraphim-it/tests/marketplace_plan_test.py index f9f2d65d2..dfb833d3e 100644 --- a/vs-code-terraphim-it/tests/marketplace_plan_test.py +++ b/vs-code-terraphim-it/tests/marketplace_plan_test.py @@ -17,7 +17,7 @@ def run_plan(self, flags): with tempfile.TemporaryDirectory() as directory: folder = Path(directory) environment = {key: value for key, value in os.environ.items() if key not in FLAGS} - environment.update({"GITHUB_SHA": "a" * 40, "GITHUB_REF_NAME": "terraphim_vscode-v0.1.1", + environment.update({"GITHUB_SHA": "a" * 40, "GITHUB_REF_NAME": "terraphim_vscode-v" + json.loads((ROOT / "package.json").read_text())["version"], "GITHUB_OUTPUT": str(folder / "outputs"), "GITHUB_STEP_SUMMARY": str(folder / "summary"), **flags}) result = subprocess.run([sys.executable, str(SCRIPT), "--receipt", str(folder / "receipt.json")], @@ -84,7 +84,7 @@ def test_existing_receipt_is_not_overwritten(self): receipt.write_text("earlier publication evidence\n") result = subprocess.run([sys.executable, str(SCRIPT), "--receipt", str(receipt)], env={**os.environ, "GITHUB_SHA": "a" * 40, - "GITHUB_REF_NAME": "terraphim_vscode-v0.1.1"}, + "GITHUB_REF_NAME": "terraphim_vscode-v" + json.loads((ROOT / "package.json").read_text())["version"]}, capture_output=True, text=True) self.assertNotEqual(result.returncode, 0) self.assertEqual(receipt.read_text(), "earlier publication evidence\n")