create-interview-cockpit 0.28.0 → 0.29.0

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.
@@ -269,6 +269,29 @@ require("fs").readdirSync(".").forEach((f) => console.log(" -", f));
269
269
 
270
270
  # Docs are reviewed by the docs writer.
271
271
  *.md @acme/docs @carol
272
+ `,
273
+
274
+ // PR template — github.com auto-fills the PR description from this
275
+ // file. Drop any *.md inside .github/PULL_REQUEST_TEMPLATE/ to get a
276
+ // multi-template picker instead.
277
+ ".github/pull_request_template.md": `## Summary
278
+
279
+ <!-- Brief description of what this PR does and why. -->
280
+
281
+ ## Changes
282
+
283
+ -
284
+
285
+ ## Testing
286
+
287
+ - [ ] Ran \`act -j build\` locally
288
+ - [ ] Updated tests
289
+ - [ ] Updated docs
290
+
291
+ ## Checklist
292
+
293
+ - [ ] PR title follows conventional-commit style
294
+ - [ ] Linked the related issue (Closes #...)
272
295
  `,
273
296
  };
274
297
 
@@ -489,6 +512,1675 @@ li {
489
512
  `,
490
513
  };
491
514
 
515
+ // ─── Platform Governance Template ────────────────────────────────────────
516
+ //
517
+ // Mirrors a real-world "PLF-governance" mono-repo: one repo that owns
518
+ // Azure PIM/RBAC, Azure Policy, AWS IAM, and user offboarding — all as
519
+ // code, gated by CODEOWNERS + PR template + automated pipelines.
520
+ //
521
+ // In the lab the focus is the .github/ governance plumbing (CODEOWNERS,
522
+ // PR template, validation workflow, deploy workflows). The cloud config
523
+ // files (Terraform, policy JSON) are included so learners can read them,
524
+ // but the workflows are what the lab actually runs.
525
+
526
+ const GOVERNANCE_FILES: Record<string, string> = {
527
+ ".github/CODEOWNERS": `# CODEOWNERS for platform-governance.
528
+ #
529
+ # WHAT THIS FILE DOES
530
+ # -------------------
531
+ # GitHub auto-requests review from these owners whenever a matching path
532
+ # changes. Combined with branch protection ("require review from Code
533
+ # Owners"), it means governance changes CANNOT be merged without the
534
+ # right team approving.
535
+ #
536
+ # WHY IT MATTERS
537
+ # --------------
538
+ # Governance is the repo that controls access to everything else. If
539
+ # anyone could merge a PR here, they could grant themselves admin
540
+ # everywhere. CODEOWNERS turns that into a hard, audit-friendly rule.
541
+ #
542
+ # SYNTAX
543
+ # ------
544
+ # <pattern> <owner1> <owner2> ...
545
+ # Last matching pattern wins.
546
+
547
+ # Default: platform team owns everything in this repo.
548
+ * @acme/platform-team
549
+
550
+ # Azure governance — PIM/RBAC and Azure Policy require platform + sec review.
551
+ /azure-pim-solution/ @acme/platform-team @acme/security
552
+ /azure-policy-solution/ @acme/platform-team @acme/security
553
+
554
+ # AWS governance — same idea, plus AWS-specific reviewers.
555
+ /aws-governance/ @acme/platform-team @acme/aws-admins
556
+
557
+ # User lifecycle — IT/IAM team co-owns offboarding logic.
558
+ /user-management/ @acme/platform-team @acme/iam
559
+
560
+ # .github changes (workflows, this file, PR template) are highest-trust.
561
+ /.github/ @acme/platform-leads
562
+ `,
563
+ ".github/pull_request_template.md": `<!--
564
+ PR TEMPLATE
565
+ ===========
566
+ This template loads automatically when a contributor opens a PR.
567
+ It forces them to declare WHAT changes, WHERE it deploys, and WHETHER
568
+ it has been validated — so reviewers (and auditors later) have the
569
+ context required to approve a governance change.
570
+
571
+ Senior signal: the repo *governs the process of changing governance*.
572
+ This is what separates a real platform team from a script collection.
573
+ -->
574
+
575
+ ## ⚠️ Heads-up
576
+
577
+ Merging to \`main\` may **automatically deploy to production** via the
578
+ workflows in \`.github/workflows/\`. Re-read your diff before requesting
579
+ review.
580
+
581
+ ---
582
+
583
+ ## Type of change
584
+
585
+ <!-- Pick one. Helps reviewers know what risk to expect. -->
586
+
587
+ - [ ] Azure PIM / RBAC assignment (user, group, or SPN)
588
+ - [ ] Azure Policy / initiative
589
+ - [ ] AWS IAM role / permission set / policy
590
+ - [ ] User offboarding configuration
591
+ - [ ] CI / workflow / repo governance
592
+ - [ ] Documentation only
593
+
594
+ ## Environments affected
595
+
596
+ - [ ] Test
597
+ - [ ] Staging
598
+ - [ ] Production
599
+ - [ ] FedRAMP Test
600
+ - [ ] FedRAMP Prod
601
+
602
+ ## Pre-merge checklist
603
+
604
+ - [ ] Object IDs (user/group/SPN) verified to exist in the target tenant
605
+ - [ ] Policy / role tested in **Test** before promotion
606
+ - [ ] No secrets committed (use OIDC / federated identity, not keys)
607
+ - [ ] Platform team notified in \`#platform-governance\` if scope is large
608
+ - [ ] Terraform \`plan\` reviewed in CI artifacts
609
+
610
+ ## What does this change do?
611
+
612
+ <!-- Plain English, 2–4 sentences. Pretend the reviewer is on call. -->
613
+
614
+ ## How was it tested?
615
+
616
+ <!-- e.g. "applied to test subscription, confirmed role assignment in
617
+ Azure portal, ran \`terraform plan\` against staging — no drift." -->
618
+
619
+ ## Rollback plan
620
+
621
+ <!-- How do we undo this if it breaks production after merge? -->
622
+ `,
623
+ ".github/workflows/aws-governance-deploy.yml": `# Deploy AWS IAM roles, permission sets, and policies via Terraform.
624
+ #
625
+ # Notice the structural twin to azure-pim-deploy.yml — *that consistency
626
+ # is itself a governance signal*. Multi-cloud governance is much easier
627
+ # to audit when every cloud's pipeline looks the same.
628
+
629
+ name: AWS governance deploy
630
+
631
+ on:
632
+ push:
633
+ branches: [main]
634
+ paths: ["aws-governance/**"]
635
+ workflow_dispatch:
636
+
637
+ permissions:
638
+ contents: read
639
+ id-token: write
640
+
641
+ jobs:
642
+ deploy:
643
+ strategy:
644
+ fail-fast: true
645
+ max-parallel: 1
646
+ matrix:
647
+ environment: [test, staging, prod]
648
+ runs-on: ubuntu-latest
649
+ environment: \${{ matrix.environment }}
650
+ concurrency:
651
+ group: aws-governance-\${{ matrix.environment }}
652
+ steps:
653
+ - uses: actions/checkout@v4
654
+
655
+ # OIDC -> AWS. The role's trust policy restricts which repo and
656
+ # which branch can assume it (see aws-governance/iam/github-oidc-role.tf
657
+ # in a real repo). No static AWS keys anywhere.
658
+ - uses: aws-actions/configure-aws-credentials@v4
659
+ with:
660
+ role-to-assume: \${{ vars.AWS_DEPLOY_ROLE_ARN }}
661
+ aws-region: us-east-1
662
+
663
+ - uses: hashicorp/setup-terraform@v3
664
+ - working-directory: aws-governance
665
+ run: |
666
+ terraform init -backend-config=envs/\${{ matrix.environment }}.backend.hcl
667
+ terraform apply -auto-approve -var-file=envs/\${{ matrix.environment }}.tfvars
668
+ `,
669
+ ".github/workflows/azure-pim-deploy.yml": `# Deploy Azure PIM + RBAC assignments.
670
+ #
671
+ # TRIGGERED BY
672
+ # ------------
673
+ # - Push to main (after PR review) -> deploy to test, then staging, then prod.
674
+ # - Manual dispatch -> targeted env, useful for re-runs / drift correction.
675
+ # - Schedule -> nightly drift check (no apply, just plan).
676
+ #
677
+ # DESIGN NOTES
678
+ # ------------
679
+ # - Environments use GitHub "Environment" protection rules so prod
680
+ # requires a separate approver beyond the PR review.
681
+ # - Concurrency group prevents two deploys racing on the same env state.
682
+ # - OIDC federation, no long-lived service principal secrets.
683
+
684
+ name: Azure PIM deploy
685
+
686
+ on:
687
+ push:
688
+ branches: [main]
689
+ paths: ["azure-pim-solution/**"]
690
+ workflow_dispatch:
691
+ inputs:
692
+ environment:
693
+ type: choice
694
+ options: [test, staging, prod, fedramp-test, fedramp-prod]
695
+ default: test
696
+ schedule:
697
+ # Nightly drift check at 02:00 UTC. Plan only, no apply.
698
+ - cron: "0 2 * * *"
699
+
700
+ permissions:
701
+ contents: read
702
+ id-token: write
703
+
704
+ jobs:
705
+ plan-and-apply:
706
+ strategy:
707
+ # Promote sequentially: test -> staging -> prod. If test fails,
708
+ # later envs never run. fail-fast=true is the safer default here.
709
+ fail-fast: true
710
+ max-parallel: 1
711
+ matrix:
712
+ environment:
713
+ - test
714
+ - staging
715
+ - prod
716
+ runs-on: ubuntu-latest
717
+ environment: \${{ matrix.environment }}
718
+ concurrency:
719
+ # One deploy per env at a time. Avoids racing Terraform state.
720
+ group: azure-pim-\${{ matrix.environment }}
721
+ cancel-in-progress: false
722
+ steps:
723
+ - uses: actions/checkout@v4
724
+
725
+ - uses: azure/login@v2
726
+ with:
727
+ client-id: \${{ vars.AZURE_DEPLOY_CLIENT_ID }}
728
+ tenant-id: \${{ vars.AZURE_TENANT_ID }}
729
+ subscription-id: \${{ vars.AZURE_SUBSCRIPTION_ID }}
730
+
731
+ - uses: hashicorp/setup-terraform@v3
732
+
733
+ - name: Terraform init
734
+ working-directory: azure-pim-solution
735
+ run: terraform init -backend-config=envs/\${{ matrix.environment }}.backend.hcl
736
+
737
+ - name: Terraform plan
738
+ working-directory: azure-pim-solution
739
+ run: terraform plan -var-file=envs/\${{ matrix.environment }}.tfvars -out=tfplan
740
+
741
+ # Drift-only mode: scheduled nightly run stops here.
742
+ - name: Skip apply on schedule
743
+ if: github.event_name == 'schedule'
744
+ run: echo "Drift check complete; not applying."
745
+
746
+ - name: Terraform apply
747
+ if: github.event_name != 'schedule'
748
+ working-directory: azure-pim-solution
749
+ run: terraform apply -auto-approve tfplan
750
+ `,
751
+ ".github/workflows/azure-policy-deploy.yml": `# Deploy Azure Policy definitions + assignments.
752
+ #
753
+ # Azure Policy is the rule engine that audits or blocks resources at
754
+ # create/update time (e.g. "deny storage accounts without TLS 1.2").
755
+ #
756
+ # Same promotion model as PIM: test -> staging -> prod.
757
+
758
+ name: Azure Policy deploy
759
+
760
+ on:
761
+ push:
762
+ branches: [main]
763
+ paths: ["azure-policy-solution/**"]
764
+ workflow_dispatch:
765
+
766
+ permissions:
767
+ contents: read
768
+ id-token: write
769
+
770
+ jobs:
771
+ deploy:
772
+ strategy:
773
+ fail-fast: true
774
+ max-parallel: 1
775
+ matrix:
776
+ environment: [test, staging, prod]
777
+ runs-on: ubuntu-latest
778
+ environment: \${{ matrix.environment }}
779
+ concurrency:
780
+ group: azure-policy-\${{ matrix.environment }}
781
+ steps:
782
+ - uses: actions/checkout@v4
783
+
784
+ - uses: azure/login@v2
785
+ with:
786
+ client-id: \${{ vars.AZURE_DEPLOY_CLIENT_ID }}
787
+ tenant-id: \${{ vars.AZURE_TENANT_ID }}
788
+ subscription-id: \${{ vars.AZURE_SUBSCRIPTION_ID }}
789
+
790
+ # Step 1: register / update every policy DEFINITION (the "rule").
791
+ - name: Upsert policy definitions
792
+ run: |
793
+ for f in azure-policy-solution/policies/*.json; do
794
+ name=$(jq -r '.name' "$f")
795
+ az policy definition create \\
796
+ --name "$name" \\
797
+ --rules "$(jq -c '.properties.policyRule' "$f")" \\
798
+ --params "$(jq -c '.properties.parameters // {}' "$f")" \\
799
+ --mode All \\
800
+ --description "$(jq -r '.properties.description' "$f")"
801
+ done
802
+
803
+ # Step 2: register / update INITIATIVES (a bundle of policies).
804
+ - name: Upsert initiatives
805
+ run: |
806
+ for f in azure-policy-solution/initiatives/*.json; do
807
+ az policy set-definition create \\
808
+ --name "$(jq -r '.name' "$f")" \\
809
+ --definitions "$(jq -c '.properties.policyDefinitions' "$f")"
810
+ done
811
+
812
+ # Step 3: ASSIGN initiatives to scopes (subscriptions / mgmt groups)
813
+ # via Terraform, so the assignments are tracked in state and can drift-correct.
814
+ - uses: hashicorp/setup-terraform@v3
815
+ - working-directory: azure-policy-solution/assignments
816
+ run: |
817
+ terraform init -backend-config=envs/\${{ matrix.environment }}.backend.hcl
818
+ terraform apply -auto-approve -var-file=envs/\${{ matrix.environment }}.tfvars
819
+ `,
820
+ ".github/workflows/pr_validations.yml": `# PR validation workflow.
821
+ #
822
+ # WHAT THIS DOES
823
+ # --------------
824
+ # Runs on every PR to enforce baseline hygiene before reviewers spend
825
+ # time on it: PR title format, Terraform fmt/validate, JSON schema for
826
+ # Azure Policy files, and object ID validation for any RBAC assignments.
827
+ #
828
+ # WHY IT MATTERS
829
+ # --------------
830
+ # - Catches "fat-finger" object IDs *before* a deploy fails noisily in prod.
831
+ # - Forces conventional PR titles so the changelog is readable.
832
+ # - Makes "did the author actually run terraform fmt?" not a review topic.
833
+
834
+ name: PR validations
835
+
836
+ on:
837
+ pull_request:
838
+ branches: [main, "release/*"]
839
+
840
+ # Least-privilege token. Workflows should never use the default
841
+ # read-write token unless they truly need it.
842
+ permissions:
843
+ contents: read
844
+ pull-requests: read
845
+ id-token: write # for OIDC federation to Azure / AWS for read-only checks
846
+
847
+ jobs:
848
+ pr-title:
849
+ name: Conventional PR title
850
+ runs-on: ubuntu-latest
851
+ steps:
852
+ - uses: amannn/action-semantic-pull-request@v5
853
+ env:
854
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
855
+ with:
856
+ # e.g. "feat(azure-pim): add platform-operator role assignment"
857
+ types: |
858
+ feat
859
+ fix
860
+ chore
861
+ docs
862
+ refactor
863
+ ci
864
+ requireScope: true
865
+
866
+ terraform:
867
+ name: Terraform fmt + validate
868
+ runs-on: ubuntu-latest
869
+ strategy:
870
+ matrix:
871
+ # Each governance area has its own Terraform root module.
872
+ dir:
873
+ - azure-pim-solution
874
+ - azure-policy-solution/assignments
875
+ - aws-governance
876
+ steps:
877
+ - uses: actions/checkout@v4
878
+ - uses: hashicorp/setup-terraform@v3
879
+ - run: terraform -chdir=\${{ matrix.dir }} fmt -check -recursive
880
+ - run: terraform -chdir=\${{ matrix.dir }} init -backend=false
881
+ - run: terraform -chdir=\${{ matrix.dir }} validate
882
+
883
+ azure-policy-schema:
884
+ name: Validate Azure Policy JSON
885
+ runs-on: ubuntu-latest
886
+ steps:
887
+ - uses: actions/checkout@v4
888
+ - name: Validate every policy definition has required fields
889
+ # Tiny shell check — real repos use a proper JSON schema, but this
890
+ # makes the *intent* obvious in a learning template.
891
+ run: |
892
+ set -euo pipefail
893
+ for f in azure-policy-solution/policies/*.json; do
894
+ jq -e '.properties.policyType and .properties.policyRule' "$f" >/dev/null \\
895
+ || { echo "::error file=$f::missing properties.policyType or .policyRule"; exit 1; }
896
+ done
897
+
898
+ object-id-validation:
899
+ name: Validate Azure object IDs exist
900
+ runs-on: ubuntu-latest
901
+ steps:
902
+ - uses: actions/checkout@v4
903
+ # OIDC = OpenID Connect. Lets GitHub Actions assume an Azure
904
+ # identity WITHOUT storing a long-lived secret. Big security win.
905
+ - uses: azure/login@v2
906
+ with:
907
+ client-id: \${{ vars.AZURE_RO_CLIENT_ID }}
908
+ tenant-id: \${{ vars.AZURE_TENANT_ID }}
909
+ subscription-id: \${{ vars.AZURE_SUBSCRIPTION_ID }}
910
+ - name: Check every principalId in PIM configs
911
+ run: ./scripts/validate-object-ids.sh azure-pim-solution
912
+ `,
913
+ ".github/workflows/user-offboarding.yml": `# Scheduled user offboarding.
914
+ #
915
+ # WHAT THIS DOES
916
+ # --------------
917
+ # Compares the source-of-truth tenant (where HR-controlled identities
918
+ # live) against R+D and SaaS tenants. Anyone present in R+D / SaaS but
919
+ # missing from the source-of-truth gets removed.
920
+ #
921
+ # WHY IT'S SCHEDULED
922
+ # ------------------
923
+ # Offboarding is a *continuous* governance concern. People leave between
924
+ # deploys. Running on a cron means stale access shrinks toward zero
925
+ # without a human having to remember.
926
+ #
927
+ # SAFETY RAIL
928
+ # -----------
929
+ # If the diff would remove > N users (default 10), the workflow fails
930
+ # loudly and refuses to proceed. Real platform teams burn themselves
931
+ # *exactly once* on a runaway cleanup script before they add this rail.
932
+
933
+ name: User offboarding
934
+
935
+ on:
936
+ schedule:
937
+ - cron: "0 6 * * *" # daily at 06:00 UTC
938
+ workflow_dispatch:
939
+ inputs:
940
+ dry_run:
941
+ type: boolean
942
+ default: true
943
+
944
+ permissions:
945
+ contents: read
946
+ id-token: write
947
+
948
+ jobs:
949
+ offboard:
950
+ runs-on: ubuntu-latest
951
+ environment: prod # protects production with required reviewers if dispatched manually
952
+ steps:
953
+ - uses: actions/checkout@v4
954
+
955
+ - uses: azure/login@v2
956
+ with:
957
+ client-id: \${{ vars.AZURE_OFFBOARD_CLIENT_ID }}
958
+ tenant-id: \${{ vars.AZURE_TENANT_ID }}
959
+
960
+ - name: Run offboarding
961
+ env:
962
+ MAX_DELETIONS: "10"
963
+ SENDGRID_API_KEY: \${{ secrets.SENDGRID_API_KEY }}
964
+ DRY_RUN: \${{ inputs.dry_run || 'false' }}
965
+ run: pwsh -File ./user-management/scripts/offboard-users.ps1
966
+ `,
967
+ "README.md": `# Platform Governance Template
968
+
969
+ > A learning-friendly clone of a real **platform governance mono-repo**
970
+ > (the kind a Cloud Platform / Platform Engineering team owns at a large
971
+ > company). Use it to study, demo in interviews, or push to GitHub as a
972
+ > real template repo.
973
+
974
+ ---
975
+
976
+ ## What this repo is
977
+
978
+ This is the **central control repo** for defining and automatically
979
+ applying the rules, permissions, policies, and identity setup used
980
+ across a multi-cloud platform.
981
+
982
+ In one sentence:
983
+
984
+ > **Governance as code** — version-controlled config + automated
985
+ > pipelines that keep cloud access, security policies, and user lifecycle
986
+ > consistent across Azure, AWS, and multiple environments.
987
+
988
+ It is **not** an end-user product. Think of it as the **control room**
989
+ behind the scenes.
990
+
991
+ ---
992
+
993
+ ## What “governance” means here
994
+
995
+ The platform team uses this repo to make sure cloud environments stay:
996
+
997
+ 1. **secure** — least-privilege access, no rogue admins
998
+ 2. **consistent** — same standards in test, staging, prod, and FedRAMP
999
+ 3. **auditable** — every change is a reviewed pull request
1000
+ 4. **automated** — pipelines apply changes, not human clicks
1001
+ 5. **harder to misconfigure** — policies block bad resources at create time
1002
+
1003
+ > **FedRAMP** = Federal Risk and Authorization Management Program — a
1004
+ > U.S. government cloud security/compliance baseline. If a repo
1005
+ > mentions "FedRAMP Test/Prod", it usually means stricter controls and
1006
+ > a separate isolated tenant.
1007
+
1008
+ ---
1009
+
1010
+ ## The four governance areas in this mono-repo
1011
+
1012
+ | Folder | Area | Cloud | What it controls |
1013
+ |---|---|---|---|
1014
+ | [\`azure-pim-solution/\`](./azure-pim-solution/) | PIM + RBAC | Azure | Who can elevate to admin, and through which roles |
1015
+ | [\`azure-policy-solution/\`](./azure-policy-solution/) | Policy as Code | Azure | Required tags, TLS, naming, diagnostics |
1016
+ | [\`aws-governance/\`](./aws-governance/) | IAM + permission sets | AWS | IAM roles, SSO permission sets, deny-policies |
1017
+ | [\`user-management/\`](./user-management/) | Offboarding automation | Cross-cloud | Removes stale users from tenants + SaaS |
1018
+
1019
+ > **Mono-repo** = one repository containing several related solutions.
1020
+ > Each subfolder could be its own repo, but keeping them together makes
1021
+ > review, ownership, and cross-cutting changes easier.
1022
+
1023
+ ---
1024
+
1025
+ ## How a change actually flows
1026
+
1027
+ \`\`\`
1028
+ ┌────────────┐ ┌──────────┐ ┌─────────┐ ┌───────────┐ ┌──────────┐
1029
+ │ Edit a │ -> │ Open PR │ -> │ Reviews │ -> │ Merge to │ -> │ Pipeline │
1030
+ │ config │ │ │ │ + CI │ │ main │ │ deploys │
1031
+ └────────────┘ └──────────┘ └─────────┘ └───────────┘ └──────────┘
1032
+ │ │ │ │ │
1033
+ │ PR template CODEOWNERS Branch ruleset GitHub Actions
1034
+ │ (.github/) (.github/) (GitHub UI) (workflows/)
1035
+
1036
+ You edit .tf / .json files describing
1037
+ the DESIRED STATE of the cloud.
1038
+ \`\`\`
1039
+
1040
+ The repo never asks *"what is currently in Azure?"* — it asks
1041
+ *"what **should** be there?"* and lets automation reconcile reality.
1042
+
1043
+ See [docs/CHANGE-FLOW.md](./docs/CHANGE-FLOW.md) for the full walkthrough.
1044
+
1045
+ ---
1046
+
1047
+ ## How to use this template
1048
+
1049
+ 1. Click **Use this template** on GitHub (after pushing it).
1050
+ 2. Replace placeholder Azure tenant IDs, AWS account IDs, and team
1051
+ handles with real ones.
1052
+ 3. Wire the workflows to your cloud credentials (OIDC recommended; see
1053
+ each \`workflows/*.yml\` for the federated identity stub).
1054
+ 4. Read [\`docs/GLOSSARY.md\`](./docs/GLOSSARY.md) first if PIM, RBAC,
1055
+ IAM, Terraform, and Azure Policy are new to you.
1056
+
1057
+ ---
1058
+
1059
+ ## Strong governance signals to look for
1060
+
1061
+ If you’re reviewing a real repo like this in an interview or audit,
1062
+ these are the high-value signals:
1063
+
1064
+ - **CODEOWNERS** restricts who can approve governance changes
1065
+ - **PR template** forces declaration of impact + environments
1066
+ - **PR validation workflow** standardizes change format
1067
+ - **Object ID validation** before deploy (no fake principals slip through)
1068
+ - **Terraform state** tracks managed resources and detects drift
1069
+ - **Scheduled runs** continuously reconcile cloud reality with repo
1070
+ - **Backstage \`catalog-info.yaml\`** registers governance as a real
1071
+ internal platform service with an owner and lifecycle
1072
+
1073
+ All of those are present in this template.
1074
+
1075
+ ---
1076
+
1077
+ ## One-line interview answer
1078
+
1079
+ > A platform governance mono-repo that manages cloud access, policy
1080
+ > standards, and user lifecycle as code across Azure and AWS — with
1081
+ > mandatory reviews, PR validations, and automated pipelines that deploy
1082
+ > approved changes consistently across test, staging, prod, and FedRAMP.
1083
+ `,
1084
+ "aws-governance/README.md": `# AWS governance
1085
+
1086
+ > AWS uses different primitives than Azure but the *governance pattern*
1087
+ > is identical: define identity + permissions as code, gate changes
1088
+ > through PR review, deploy via Terraform pipeline.
1089
+ >
1090
+ > Notice how the folder layout mirrors \`azure-pim-solution/\`. That
1091
+ > consistency is deliberate — auditors and new engineers can reason
1092
+ > about both clouds with the same mental model.
1093
+
1094
+ ---
1095
+
1096
+ ## Key AWS concepts
1097
+
1098
+ | AWS term | Plain English | Azure equivalent |
1099
+ |---|---|---|
1100
+ | **IAM User** | Long-lived human/app credential. Avoid these. | Azure AD user |
1101
+ | **IAM Role** | A set of permissions that *something* can assume temporarily. | Azure RBAC role assignment (closer to PIM-eligible) |
1102
+ | **IAM Policy** | The actual JSON list of \`Allow\`/\`Deny\` statements. | Azure role definition |
1103
+ | **Permission Set** (in AWS Identity Center / SSO) | A pre-baked role users get when they log in via SSO. | Entra group → role assignment |
1104
+ | **SCP** (Service Control Policy) | Org-wide deny rule applied to whole accounts. | Azure Policy at management group |
1105
+ | **OIDC trust policy** | Lets GitHub Actions (or another IdP) assume a role without secrets. | Azure federated credential |
1106
+
1107
+ ---
1108
+
1109
+ ## Folder layout
1110
+
1111
+ \`\`\`
1112
+ aws-governance/
1113
+ ├── iam/
1114
+ │ ├── platform-admin-role.tf # role for platform team SSO users
1115
+ │ └── developer-permission-set.tf # SSO permission set for app teams
1116
+ └── policies/
1117
+ └── deny-root-actions.json # SCP: nobody uses the root account
1118
+ \`\`\`
1119
+
1120
+ ---
1121
+
1122
+ ## How a change lands
1123
+
1124
+ Same flow as Azure: PR → CODEOWNERS review → merge → \`aws-governance-deploy.yml\`
1125
+ runs Terraform per environment using OIDC-federated credentials.
1126
+
1127
+ The \`role-to-assume\` in CI has a **trust policy** that restricts the
1128
+ role to *this exact repo on the main branch*. That's how you safely
1129
+ let CI hold cloud admin without giving developers the keys.
1130
+ `,
1131
+ "aws-governance/iam/developer-permission-set.tf": `# Permission Set assigned to developers via AWS Identity Center (SSO).
1132
+ #
1133
+ # A "permission set" in AWS SSO = the bundle that becomes an IAM Role
1134
+ # in each member account when a user is granted it. So defining it once
1135
+ # here gives developers consistent access across every AWS account in
1136
+ # the organisation.
1137
+
1138
+ resource "aws_ssoadmin_permission_set" "developer_readonly" {
1139
+ name = "DeveloperReadOnly"
1140
+ description = "Read-only access for developers. Includes CloudWatch Logs read so they can debug their apps."
1141
+ instance_arn = var.sso_instance_arn
1142
+ session_duration = "PT4H" # 4 hours — short sessions reduce token theft blast radius
1143
+ }
1144
+
1145
+ # AWS-managed policy gives broad read-only.
1146
+ resource "aws_ssoadmin_managed_policy_attachment" "developer_readonly" {
1147
+ instance_arn = var.sso_instance_arn
1148
+ managed_policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
1149
+ permission_set_arn = aws_ssoadmin_permission_set.developer_readonly.arn
1150
+ }
1151
+
1152
+ # Inline policy: extra grants we cannot get from a managed policy.
1153
+ # Keeping these tiny and named is much easier to review than one giant
1154
+ # 200-line custom policy.
1155
+ resource "aws_ssoadmin_permission_set_inline_policy" "developer_readonly_logs" {
1156
+ instance_arn = var.sso_instance_arn
1157
+ permission_set_arn = aws_ssoadmin_permission_set.developer_readonly.arn
1158
+ inline_policy = jsonencode({
1159
+ Version = "2012-10-17"
1160
+ Statement = [{
1161
+ Effect = "Allow"
1162
+ Action = [
1163
+ "logs:GetLogEvents",
1164
+ "logs:FilterLogEvents",
1165
+ "logs:StartQuery",
1166
+ "logs:StopQuery",
1167
+ "logs:GetQueryResults"
1168
+ ]
1169
+ Resource = "*"
1170
+ }]
1171
+ })
1172
+ }
1173
+
1174
+ variable "sso_instance_arn" { type = string }
1175
+ `,
1176
+ "aws-governance/iam/platform-admin-role.tf": `# IAM Role used by the platform team via AWS SSO (Identity Center).
1177
+ #
1178
+ # The role's *trust policy* controls WHO can assume it.
1179
+ # The attached *managed policies* control WHAT they can do once assumed.
1180
+ # Splitting the two is what makes IAM least-privilege thinking work.
1181
+
1182
+ resource "aws_iam_role" "platform_admin" {
1183
+ name = "platform-admin"
1184
+ description = "Assumed by platform team members via AWS SSO. Tightly scoped — does NOT include billing or org-level write."
1185
+
1186
+ # Trust policy: only the SSO-managed identity provider can let users
1187
+ # assume this role, and only from sessions tagged with the platform
1188
+ # group. No long-lived keys, no other accounts.
1189
+ assume_role_policy = jsonencode({
1190
+ Version = "2012-10-17"
1191
+ Statement = [{
1192
+ Effect = "Allow"
1193
+ Principal = {
1194
+ Federated = "arn:aws:iam::\${var.account_id}:saml-provider/AWSSSO"
1195
+ }
1196
+ Action = "sts:AssumeRoleWithSAML"
1197
+ Condition = {
1198
+ StringEquals = {
1199
+ "SAML:aud" = "https://signin.aws.amazon.com/saml"
1200
+ }
1201
+ }
1202
+ }]
1203
+ })
1204
+
1205
+ # Permissions boundary = a hard ceiling. Even if someone attaches a
1206
+ # broader policy by mistake, the boundary still wins. Senior signal.
1207
+ permissions_boundary = aws_iam_policy.platform_admin_boundary.arn
1208
+
1209
+ tags = {
1210
+ "owner" = "platform-team"
1211
+ "managed-by" = "platform-governance-repo"
1212
+ "environment" = var.environment
1213
+ }
1214
+ }
1215
+
1216
+ # The boundary explicitly denies dangerous actions that platform admins
1217
+ # should never need (org root, billing, deleting CloudTrail).
1218
+ resource "aws_iam_policy" "platform_admin_boundary" {
1219
+ name = "platform-admin-boundary"
1220
+ description = "Permissions boundary for the platform-admin role."
1221
+ policy = jsonencode({
1222
+ Version = "2012-10-17"
1223
+ Statement = [
1224
+ {
1225
+ Effect = "Allow"
1226
+ Action = "*"
1227
+ Resource = "*"
1228
+ },
1229
+ {
1230
+ Effect = "Deny"
1231
+ Action = [
1232
+ "organizations:*",
1233
+ "account:*",
1234
+ "aws-portal:*",
1235
+ "cloudtrail:DeleteTrail",
1236
+ "cloudtrail:StopLogging"
1237
+ ]
1238
+ Resource = "*"
1239
+ }
1240
+ ]
1241
+ })
1242
+ }
1243
+
1244
+ variable "account_id" { type = string }
1245
+ variable "environment" { type = string }
1246
+ `,
1247
+ "aws-governance/policies/deny-root-actions.json": `{
1248
+ "Version": "2012-10-17",
1249
+ "Statement": [
1250
+ {
1251
+ "Sid": "DenyAllRootUserActions",
1252
+ "Effect": "Deny",
1253
+ "Action": "*",
1254
+ "Resource": "*",
1255
+ "Condition": {
1256
+ "StringLike": {
1257
+ "aws:PrincipalArn": "arn:aws:iam::*:root"
1258
+ }
1259
+ }
1260
+ },
1261
+ {
1262
+ "Sid": "DenyDisablingSecurityServices",
1263
+ "Effect": "Deny",
1264
+ "Action": [
1265
+ "guardduty:DeleteDetector",
1266
+ "guardduty:StopMonitoringMembers",
1267
+ "config:DeleteConfigurationRecorder",
1268
+ "config:StopConfigurationRecorder",
1269
+ "cloudtrail:StopLogging",
1270
+ "cloudtrail:DeleteTrail"
1271
+ ],
1272
+ "Resource": "*"
1273
+ }
1274
+ ]
1275
+ }
1276
+ `,
1277
+ "azure-pim-solution/README.md": `# Azure PIM + RBAC as code
1278
+
1279
+ > **PIM** = Privileged Identity Management — Microsoft's *just-in-time*
1280
+ > elevation system. Instead of being a permanent admin, you "activate"
1281
+ > the role for a few hours after MFA + (optionally) approval.
1282
+ >
1283
+ > **RBAC** = Role-Based Access Control — permissions are granted via
1284
+ > roles (Reader, Contributor, custom roles), assigned at a scope
1285
+ > (management group, subscription, resource group, resource).
1286
+
1287
+ This solution stores the *desired state* of who can do what in Azure,
1288
+ and a Terraform pipeline reconciles Azure to match.
1289
+
1290
+ ---
1291
+
1292
+ ## Folder layout
1293
+
1294
+ \`\`\`
1295
+ azure-pim-solution/
1296
+ ├── main.tf # wires modules + providers
1297
+ ├── variables.tf # env-level inputs (subscription IDs, etc.)
1298
+ ├── envs/ # per-environment tfvars + backend (gitignored secrets)
1299
+ ├── roles/ # custom Azure role DEFINITIONS (JSON)
1300
+ ├── users/ # human identity ASSIGNMENTS (one .tf per user)
1301
+ └── spns/ # Service Principal (non-human) ASSIGNMENTS
1302
+ \`\`\`
1303
+
1304
+ ### Why split \`users/\` and \`spns/\`?
1305
+
1306
+ > **SPN** = Service Principal — a non-human identity used by apps,
1307
+ > pipelines, automation. They have very different review requirements
1308
+ > from human users (no MFA, no PIM activation), so keeping them in a
1309
+ > separate folder lets CODEOWNERS demand stricter approval on
1310
+ > \`spns/\` if needed.
1311
+
1312
+ ---
1313
+
1314
+ ## How a new role assignment lands in Azure
1315
+
1316
+ 1. Engineer adds a \`.tf\` file under \`users/\` or \`spns/\`.
1317
+ 2. PR opens — \`pr_validations.yml\` checks the principal's object ID exists.
1318
+ 3. CODEOWNERS forces platform-team + security review.
1319
+ 4. Merge to \`main\` triggers \`azure-pim-deploy.yml\`:
1320
+ \`test\` → \`staging\` → \`prod\`.
1321
+ 5. Nightly cron does a \`terraform plan\` (no apply) to detect drift —
1322
+ if someone clicked an assignment in the portal, the next deploy
1323
+ will *remove* it because it's not in code.
1324
+
1325
+ ---
1326
+
1327
+ ## Key idea: drift correction
1328
+
1329
+ Terraform state remembers what *this repo* manages. So if a panicked
1330
+ on-call grants someone Owner manually, the nightly drift plan flags
1331
+ it, and the next merge removes it. Governance becomes self-healing
1332
+ rather than one-shot.
1333
+ `,
1334
+ "azure-pim-solution/main.tf": `# Root module for Azure PIM + RBAC.
1335
+ #
1336
+ # Tiny on purpose: each user/SPN file in users/ and spns/ is a self-
1337
+ # contained resource block, so reviewers see a complete diff per
1338
+ # principal in one PR file instead of hunting through a giant module.
1339
+
1340
+ terraform {
1341
+ required_version = ">= 1.6.0"
1342
+
1343
+ required_providers {
1344
+ azurerm = {
1345
+ source = "hashicorp/azurerm"
1346
+ version = "~> 3.110"
1347
+ }
1348
+ azuread = {
1349
+ source = "hashicorp/azuread"
1350
+ version = "~> 2.50"
1351
+ }
1352
+ }
1353
+
1354
+ # Backend config is supplied per-env via -backend-config in CI.
1355
+ # Keeps test/staging/prod state files isolated and gives blast radius.
1356
+ backend "azurerm" {}
1357
+ }
1358
+
1359
+ provider "azurerm" {
1360
+ features {}
1361
+ subscription_id = var.subscription_id
1362
+ }
1363
+
1364
+ provider "azuread" {
1365
+ tenant_id = var.tenant_id
1366
+ }
1367
+
1368
+ # Custom role definitions live in roles/*.json. Loop over them so adding
1369
+ # a new custom role is just dropping a file in the folder.
1370
+ locals {
1371
+ custom_roles = {
1372
+ for f in fileset("\${path.module}/roles", "*.json") :
1373
+ trimsuffix(f, ".json") => jsondecode(file("\${path.module}/roles/\${f}"))
1374
+ }
1375
+ }
1376
+
1377
+ resource "azurerm_role_definition" "custom" {
1378
+ for_each = local.custom_roles
1379
+ name = each.value.Name
1380
+ scope = "/subscriptions/\${var.subscription_id}"
1381
+ description = each.value.Description
1382
+ permissions {
1383
+ actions = each.value.Actions
1384
+ not_actions = each.value.NotActions
1385
+ data_actions = lookup(each.value, "DataActions", [])
1386
+ not_data_actions = lookup(each.value, "NotDataActions", [])
1387
+ }
1388
+ assignable_scopes = each.value.AssignableScopes
1389
+ }
1390
+ `,
1391
+ "azure-pim-solution/roles/platform-operator.json": `{
1392
+ "Name": "Platform Operator",
1393
+ "Description": "Day-to-day platform ops: read everything, restart resources, rotate keys. NOT permitted to grant access or delete resources — those require activating the Owner-level break-glass role through PIM.",
1394
+ "Actions": [
1395
+ "*/read",
1396
+ "Microsoft.Compute/virtualMachines/restart/action",
1397
+ "Microsoft.Web/sites/restart/action",
1398
+ "Microsoft.KeyVault/vaults/keys/rotate/action",
1399
+ "Microsoft.Insights/diagnosticSettings/*"
1400
+ ],
1401
+ "NotActions": [
1402
+ "Microsoft.Authorization/*/Write",
1403
+ "Microsoft.Authorization/*/Delete"
1404
+ ],
1405
+ "AssignableScopes": [
1406
+ "/subscriptions/00000000-0000-0000-0000-000000000000"
1407
+ ]
1408
+ }
1409
+ `,
1410
+ "azure-pim-solution/spns/platform-deploy-spn.tf": `# SPN (Service Principal) assignment — non-human identity used by a
1411
+ # deployment pipeline. SPNs do NOT use PIM (no human to MFA/activate),
1412
+ # so they get a permanent assignment scoped as narrowly as possible.
1413
+ #
1414
+ # Senior signal: the *scope* here is a single resource group, not the
1415
+ # whole subscription. Every extra scope level is blast radius.
1416
+
1417
+ data "azuread_service_principal" "platform_deploy" {
1418
+ display_name = "platform-deploy-spn"
1419
+ }
1420
+
1421
+ resource "azurerm_role_assignment" "platform_deploy_contributor" {
1422
+ scope = "/subscriptions/\${var.subscription_id}/resourceGroups/rg-platform-\${var.environment}"
1423
+ role_definition_name = "Contributor"
1424
+ principal_id = data.azuread_service_principal.platform_deploy.object_id
1425
+ description = "Used by GitHub Actions to deploy platform infra. See PLAT-987."
1426
+ }
1427
+ `,
1428
+ "azure-pim-solution/users/octocat.tf": `# PIM-eligible role assignment for a human user.
1429
+ #
1430
+ # \`azurerm_role_assignment\` would be a *permanent* (active) grant.
1431
+ # \`azurerm_pim_eligible_role_assignment\` makes the user ELIGIBLE — they
1432
+ # must "activate" the role via PIM (MFA + optional approval) for a
1433
+ # limited time window. This is the least-privilege default for humans.
1434
+
1435
+ # Look up the user by UPN so we never hard-code object IDs in the file
1436
+ # the human reads. The data source fails the plan if the user does not
1437
+ # exist — that's the "object ID validation" governance signal.
1438
+ data "azuread_user" "octocat" {
1439
+ user_principal_name = "octocat@acme.example"
1440
+ }
1441
+
1442
+ resource "azurerm_pim_eligible_role_assignment" "octocat_platform_operator" {
1443
+ scope = "/subscriptions/\${var.subscription_id}"
1444
+ role_definition_id = azurerm_role_definition.custom["platform-operator"].role_definition_resource_id
1445
+ principal_id = data.azuread_user.octocat.object_id
1446
+
1447
+ schedule {
1448
+ start_date_time = "2026-01-01T00:00:00Z"
1449
+ expiration {
1450
+ # Hard cap on how long the *eligibility* lasts. After this,
1451
+ # the user must be re-assigned via a fresh PR. Forces periodic
1452
+ # access review — a SOC2 / ISO 27001 friendly pattern.
1453
+ end_date_time = "2026-12-31T23:59:59Z"
1454
+ }
1455
+ }
1456
+
1457
+ justification = "Day-to-day platform ops; ticketed in PLAT-1234."
1458
+ }
1459
+ `,
1460
+ "azure-pim-solution/variables.tf": `variable "subscription_id" {
1461
+ type = string
1462
+ description = "Target Azure subscription for RBAC assignments."
1463
+ }
1464
+
1465
+ variable "tenant_id" {
1466
+ type = string
1467
+ description = "Azure AD / Entra tenant the principals live in."
1468
+ }
1469
+
1470
+ variable "environment" {
1471
+ type = string
1472
+ description = "test | staging | prod | fedramp-test | fedramp-prod"
1473
+ validation {
1474
+ condition = contains(["test", "staging", "prod", "fedramp-test", "fedramp-prod"], var.environment)
1475
+ error_message = "environment must be one of test, staging, prod, fedramp-test, fedramp-prod."
1476
+ }
1477
+ }
1478
+ `,
1479
+ "azure-policy-solution/README.md": `# Azure Policy solution
1480
+
1481
+ > **Azure Policy** = Azure's built-in rule engine. Each policy is a
1482
+ > JSON object with two halves:
1483
+ >
1484
+ > - **\`if\`** — which resources does this rule care about?
1485
+ > - **\`then\`** — what should happen? (\`audit\`, \`deny\`, \`append\`, \`modify\`,
1486
+ > \`deployIfNotExists\`)
1487
+ >
1488
+ > Policies enforce things like "deny storage accounts without TLS 1.2"
1489
+ > or "audit any resource missing a \`cost-center\` tag" — at create AND
1490
+ > update time, before the resource exists.
1491
+
1492
+ ---
1493
+
1494
+ ## Folder layout
1495
+
1496
+ \`\`\`
1497
+ azure-policy-solution/
1498
+ ├── policies/ # individual policy DEFINITIONS (the rules)
1499
+ ├── initiatives/ # bundles of policies (a.k.a. policySets)
1500
+ └── assignments/ # Terraform that ASSIGNS initiatives to scopes
1501
+ \`\`\`
1502
+
1503
+ ### Why three folders?
1504
+
1505
+ This is the **define → bundle → assign** model that scales:
1506
+
1507
+ 1. **Define** a small focused rule once (e.g. "min TLS 1.2").
1508
+ 2. **Bundle** related rules into an initiative (e.g. "Platform Baseline").
1509
+ 3. **Assign** the initiative to a management group / subscription, with
1510
+ parameters per environment.
1511
+
1512
+ Without this split you end up with copy-pasted policy JSON sprinkled
1513
+ across subscriptions and no one knows what the truth is.
1514
+
1515
+ ---
1516
+
1517
+ ## How a new policy lands
1518
+
1519
+ 1. PR adds a \`.json\` to \`policies/\` (and optionally adds it to an
1520
+ initiative in \`initiatives/platform-baseline.json\`).
1521
+ 2. \`pr_validations.yml\` checks the JSON has \`policyType\` and \`policyRule\`.
1522
+ 3. CODEOWNERS forces platform + security review.
1523
+ 4. Merge → \`azure-policy-deploy.yml\`:
1524
+ - upserts every policy definition (\`az policy definition create\`)
1525
+ - upserts every initiative (\`az policy set-definition create\`)
1526
+ - runs Terraform in \`assignments/\` to bind initiatives to scopes
1527
+ 5. The next time anyone creates / updates a resource, Azure evaluates
1528
+ the policy. \`audit\` mode reports it; \`deny\` mode blocks it.
1529
+
1530
+ ---
1531
+
1532
+ ## \`audit\` first, \`deny\` later
1533
+
1534
+ Rolling out \`deny\` straight to prod breaks people. The mature pattern:
1535
+
1536
+ 1. Ship in \`audit\` mode in test → see how many resources are non-compliant.
1537
+ 2. Communicate, give teams a fix window.
1538
+ 3. Flip the parameter to \`deny\` in test → staging → prod.
1539
+
1540
+ The policies in this template expose \`effect\` as a parameter so the
1541
+ assignment in each environment can choose \`audit\` or \`deny\` without
1542
+ touching the policy definition.
1543
+ `,
1544
+ "azure-policy-solution/assignments/production.tf": `# Assign the "platform-baseline" initiative to a subscription.
1545
+ #
1546
+ # This is where the *audit vs deny* decision is made per environment.
1547
+ # - test: effect = "audit" (so devs see warnings but aren't blocked)
1548
+ # - staging: effect = "audit" (one last chance to fix)
1549
+ # - prod: effect = "deny" (real enforcement)
1550
+
1551
+ terraform {
1552
+ required_version = ">= 1.6.0"
1553
+ required_providers {
1554
+ azurerm = { source = "hashicorp/azurerm", version = "~> 3.110" }
1555
+ }
1556
+ backend "azurerm" {}
1557
+ }
1558
+
1559
+ provider "azurerm" {
1560
+ features {}
1561
+ subscription_id = var.subscription_id
1562
+ }
1563
+
1564
+ variable "subscription_id" { type = string }
1565
+ variable "environment" { type = string }
1566
+
1567
+ # Effect chosen per environment. tfvars files in envs/ override this.
1568
+ variable "baseline_effect" {
1569
+ type = string
1570
+ description = "audit | deny | disabled"
1571
+ default = "audit"
1572
+ }
1573
+
1574
+ resource "azurerm_subscription_policy_assignment" "platform_baseline" {
1575
+ name = "platform-baseline-\${var.environment}"
1576
+ display_name = "Platform baseline (\${var.environment})"
1577
+ subscription_id = "/subscriptions/\${var.subscription_id}"
1578
+ policy_definition_id = "/subscriptions/\${var.subscription_id}/providers/Microsoft.Authorization/policySetDefinitions/platform-baseline"
1579
+
1580
+ parameters = jsonencode({
1581
+ effect = { value = var.baseline_effect }
1582
+ })
1583
+
1584
+ # Identity required so deployIfNotExists / modify policies can act
1585
+ # even though this assignment uses simpler audit/deny effects today —
1586
+ # adding the identity now means future policies don't require a
1587
+ # breaking-change re-assignment.
1588
+ identity {
1589
+ type = "SystemAssigned"
1590
+ }
1591
+ location = "eastus"
1592
+ }
1593
+ `,
1594
+ "azure-policy-solution/initiatives/platform-baseline.json": `{
1595
+ "name": "platform-baseline",
1596
+ "properties": {
1597
+ "displayName": "Platform baseline initiative",
1598
+ "description": "Bundle of policies every subscription must comply with. Adding a new platform-wide rule = adding a line here, then re-assigning the initiative.",
1599
+ "policyType": "Custom",
1600
+ "metadata": {
1601
+ "category": "Platform",
1602
+ "version": "1.0.0"
1603
+ },
1604
+ "parameters": {
1605
+ "effect": {
1606
+ "type": "String",
1607
+ "allowedValues": ["audit", "deny", "disabled"],
1608
+ "defaultValue": "audit"
1609
+ }
1610
+ },
1611
+ "policyDefinitions": [
1612
+ {
1613
+ "policyDefinitionReferenceId": "require-cost-center-tag",
1614
+ "policyDefinitionId": "/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/policyDefinitions/require-cost-center-tag",
1615
+ "parameters": { "effect": { "value": "[parameters('effect')]" } }
1616
+ },
1617
+ {
1618
+ "policyDefinitionReferenceId": "storage-min-tls-1-2",
1619
+ "policyDefinitionId": "/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/policyDefinitions/storage-min-tls-1-2",
1620
+ "parameters": { "effect": { "value": "[parameters('effect')]" } }
1621
+ },
1622
+ {
1623
+ "policyDefinitionReferenceId": "storage-naming-convention",
1624
+ "policyDefinitionId": "/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/policyDefinitions/storage-naming-convention",
1625
+ "parameters": { "effect": { "value": "[parameters('effect')]" } }
1626
+ }
1627
+ ]
1628
+ }
1629
+ }
1630
+ `,
1631
+ "azure-policy-solution/policies/min-tls-version.json": `{
1632
+ "name": "storage-min-tls-1-2",
1633
+ "properties": {
1634
+ "displayName": "Storage accounts must use TLS 1.2 or higher",
1635
+ "description": "Block (or audit) any storage account whose minimumTlsVersion is below TLS1_2. Default-deny in prod; audit in test.",
1636
+ "policyType": "Custom",
1637
+ "mode": "All",
1638
+ "metadata": {
1639
+ "category": "Storage",
1640
+ "version": "1.0.0"
1641
+ },
1642
+ "parameters": {
1643
+ "effect": {
1644
+ "type": "String",
1645
+ "metadata": { "displayName": "Effect" },
1646
+ "allowedValues": ["audit", "deny", "disabled"],
1647
+ "defaultValue": "audit"
1648
+ }
1649
+ },
1650
+ "policyRule": {
1651
+ "if": {
1652
+ "allOf": [
1653
+ { "field": "type", "equals": "Microsoft.Storage/storageAccounts" },
1654
+ {
1655
+ "anyOf": [
1656
+ { "field": "Microsoft.Storage/storageAccounts/minimumTlsVersion", "exists": "false" },
1657
+ { "field": "Microsoft.Storage/storageAccounts/minimumTlsVersion", "notEquals": "TLS1_2" }
1658
+ ]
1659
+ }
1660
+ ]
1661
+ },
1662
+ "then": {
1663
+ "effect": "[parameters('effect')]"
1664
+ }
1665
+ }
1666
+ }
1667
+ }
1668
+ `,
1669
+ "azure-policy-solution/policies/require-tags.json": `{
1670
+ "name": "require-cost-center-tag",
1671
+ "properties": {
1672
+ "displayName": "Require cost-center tag on resources",
1673
+ "description": "All resources MUST carry a 'cost-center' tag so finance can chargeback. Effect is parameterised so we can audit first, then deny.",
1674
+ "policyType": "Custom",
1675
+ "mode": "Indexed",
1676
+ "metadata": {
1677
+ "category": "Tags",
1678
+ "version": "1.0.0"
1679
+ },
1680
+ "parameters": {
1681
+ "effect": {
1682
+ "type": "String",
1683
+ "metadata": { "displayName": "Effect" },
1684
+ "allowedValues": ["audit", "deny", "disabled"],
1685
+ "defaultValue": "audit"
1686
+ }
1687
+ },
1688
+ "policyRule": {
1689
+ "if": {
1690
+ "field": "tags['cost-center']",
1691
+ "exists": "false"
1692
+ },
1693
+ "then": {
1694
+ "effect": "[parameters('effect')]"
1695
+ }
1696
+ }
1697
+ }
1698
+ }
1699
+ `,
1700
+ "azure-policy-solution/policies/storage-naming-convention.json": `{
1701
+ "name": "storage-naming-convention",
1702
+ "properties": {
1703
+ "displayName": "Storage account names must follow corp naming standard",
1704
+ "description": "Enforce the corp naming standard: 'st<env><app><region>###'. Example: stprodpaymentseastus001. Helps cost reporting + ownership lookup.",
1705
+ "policyType": "Custom",
1706
+ "mode": "All",
1707
+ "metadata": {
1708
+ "category": "Naming",
1709
+ "version": "1.0.0"
1710
+ },
1711
+ "parameters": {
1712
+ "effect": {
1713
+ "type": "String",
1714
+ "metadata": { "displayName": "Effect" },
1715
+ "allowedValues": ["audit", "deny", "disabled"],
1716
+ "defaultValue": "audit"
1717
+ }
1718
+ },
1719
+ "policyRule": {
1720
+ "if": {
1721
+ "allOf": [
1722
+ { "field": "type", "equals": "Microsoft.Storage/storageAccounts" },
1723
+ { "field": "name", "notMatch": "st[a-z]{4,}[a-z]{3,}[0-9]{3}" }
1724
+ ]
1725
+ },
1726
+ "then": {
1727
+ "effect": "[parameters('effect')]"
1728
+ }
1729
+ }
1730
+ }
1731
+ }
1732
+ `,
1733
+ "catalog-info.yaml": `# Backstage catalog metadata.
1734
+ #
1735
+ # Backstage is a developer portal (open-sourced by Spotify). This file
1736
+ # registers governance as a real "internal platform service" with an
1737
+ # owner, lifecycle, and discoverable docs — so other engineers can find it.
1738
+ #
1739
+ # Senior signal: governance is treated as a *product*, not a script dump.
1740
+
1741
+ apiVersion: backstage.io/v1alpha1
1742
+ kind: System
1743
+ metadata:
1744
+ name: platform-governance
1745
+ description: Cross-cloud governance as code (Azure + AWS + user lifecycle)
1746
+ annotations:
1747
+ backstage.io/techdocs-ref: dir:.
1748
+ spec:
1749
+ owner: group:platform-team
1750
+ domain: platform
1751
+
1752
+ ---
1753
+ apiVersion: backstage.io/v1alpha1
1754
+ kind: Component
1755
+ metadata:
1756
+ name: azure-pim-rbac
1757
+ description: Azure PIM + RBAC assignments deployed via Terraform
1758
+ spec:
1759
+ type: service
1760
+ lifecycle: production
1761
+ owner: group:platform-team
1762
+ system: platform-governance
1763
+
1764
+ ---
1765
+ apiVersion: backstage.io/v1alpha1
1766
+ kind: Component
1767
+ metadata:
1768
+ name: azure-policy-solution
1769
+ description: Azure Policy definitions + initiative assignments
1770
+ spec:
1771
+ type: service
1772
+ lifecycle: production
1773
+ owner: group:platform-team
1774
+ system: platform-governance
1775
+
1776
+ ---
1777
+ apiVersion: backstage.io/v1alpha1
1778
+ kind: Component
1779
+ metadata:
1780
+ name: aws-governance
1781
+ description: AWS IAM roles, permission sets, and deny-policies
1782
+ spec:
1783
+ type: service
1784
+ lifecycle: production
1785
+ owner: group:platform-team
1786
+ system: platform-governance
1787
+
1788
+ ---
1789
+ apiVersion: backstage.io/v1alpha1
1790
+ kind: Component
1791
+ metadata:
1792
+ name: user-offboarding
1793
+ description: Scheduled cross-tenant user offboarding automation
1794
+ spec:
1795
+ type: service
1796
+ lifecycle: production
1797
+ owner: group:platform-team
1798
+ system: platform-governance
1799
+ `,
1800
+ "docs/CHANGE-FLOW.md": `# Change flow: from edit to enforcement
1801
+
1802
+ > Walks through what actually happens when an engineer edits a file in
1803
+ > this repo. Use this as your interview answer to the question
1804
+ > *"how does governance-as-code actually work day to day?"*.
1805
+
1806
+ ---
1807
+
1808
+ ## Scenario
1809
+
1810
+ Alice (a platform engineer) needs to give Bob the \`Platform Operator\`
1811
+ custom role in the **production** Azure subscription, eligible for
1812
+ one year, activated via PIM.
1813
+
1814
+ ---
1815
+
1816
+ ## Step 1 — Edit a config file
1817
+
1818
+ Alice creates \`azure-pim-solution/users/bob.tf\` (mirroring
1819
+ [\`octocat.tf\`](../azure-pim-solution/users/octocat.tf)) with Bob's UPN
1820
+ and an end date.
1821
+
1822
+ She runs \`terraform fmt\` locally. No Azure changes happen yet — the
1823
+ repo is still just files.
1824
+
1825
+ ---
1826
+
1827
+ ## Step 2 — Open a pull request
1828
+
1829
+ Alice pushes a branch and opens a PR. Several things happen automatically:
1830
+
1831
+ | Trigger | Outcome |
1832
+ |---|---|
1833
+ | \`pull_request_template.md\` loads | Forces Alice to declare type / env / pre-merge checks |
1834
+ | \`pr_validations.yml\` runs | PR title check + \`terraform fmt -check\` + \`terraform validate\` + **object ID validation** (does Bob exist in the tenant?) |
1835
+ | \`CODEOWNERS\` matches \`azure-pim-solution/\` | Auto-requests \`@acme/platform-team\` and \`@acme/security\` |
1836
+ | Branch ruleset on \`main\` | Blocks merge until 1+ approval and all required checks pass |
1837
+
1838
+ If Bob's object ID is wrong, the PR fails *here*, not at deploy time.
1839
+ That's the **shift-left** governance signal.
1840
+
1841
+ ---
1842
+
1843
+ ## Step 3 — Review
1844
+
1845
+ Reviewers see:
1846
+ - the PR description (forced by the template)
1847
+ - a focused diff (one new file, one new principal)
1848
+ - green CI showing object IDs validated and Terraform plan output
1849
+
1850
+ They approve. Alice merges.
1851
+
1852
+ ---
1853
+
1854
+ ## Step 4 — Pipeline deploys
1855
+
1856
+ \`azure-pim-deploy.yml\` triggers on push to \`main\` and:
1857
+
1858
+ 1. Authenticates to Azure via **OIDC** — no long-lived secret in the repo.
1859
+ 2. Runs \`terraform init\` against the **test** backend.
1860
+ 3. Runs \`terraform plan\` and \`apply\` for **test**.
1861
+ 4. If green, advances to **staging**, then **prod**. Each env is a
1862
+ GitHub Environment with its own approver (separate from the PR
1863
+ reviewer — segregation of duties).
1864
+ 5. The \`concurrency\` group ensures no two deploys race on the same
1865
+ state file.
1866
+
1867
+ After this, Bob is *eligible* for \`Platform Operator\` in production.
1868
+
1869
+ ---
1870
+
1871
+ ## Step 5 — Bob activates
1872
+
1873
+ Bob goes to the Azure portal → PIM → My Roles → activates \`Platform
1874
+ Operator\` for, say, 4 hours, with a justification ("PLAT-1234,
1875
+ investigating storage latency"). Azure logs the activation. After 4
1876
+ hours the access expires automatically.
1877
+
1878
+ ---
1879
+
1880
+ ## Step 6 — Drift detection
1881
+
1882
+ That night, the scheduled run of \`azure-pim-deploy.yml\` does a plan-only
1883
+ pass. If someone clicked an extra assignment in the portal, the next
1884
+ real deploy will *remove* it because it's not in code.
1885
+
1886
+ Governance becomes self-healing.
1887
+
1888
+ ---
1889
+
1890
+ ## Step 7 — Audit time
1891
+
1892
+ Six months later, an auditor asks "who approved Bob's prod access?"
1893
+ Alice opens the PR link. The PR shows:
1894
+ - the diff (the actual config that was applied)
1895
+ - the reviewer (CODEOWNERS-enforced)
1896
+ - the CI logs (object ID validation result)
1897
+ - the merge commit
1898
+ - the deploy run (linked from the merge)
1899
+
1900
+ That entire chain is the audit trail. No spreadsheets, no screenshots.
1901
+
1902
+ ---
1903
+
1904
+ ## Why this matters
1905
+
1906
+ Compare to the *without-this-repo* version:
1907
+ 1. Alice messages a senior engineer in Slack.
1908
+ 2. Senior engineer clicks around in the portal.
1909
+ 3. Maybe they forget. Maybe they grant Owner instead of Platform Operator.
1910
+ 4. There's no record six months later beyond Slack scrollback.
1911
+
1912
+ Governance-as-code converts that ad-hoc, lossy process into a
1913
+ reviewable, repeatable, auditable workflow.
1914
+ `,
1915
+ "docs/GLOSSARY.md": `# Glossary
1916
+
1917
+ > Quick definitions of every acronym in this template, written for
1918
+ > someone seeing them for the first time. Read this before the
1919
+ > sub-folder READMEs and they'll click much faster.
1920
+
1921
+ ---
1922
+
1923
+ ## Identity & access
1924
+
1925
+ **RBAC — Role-Based Access Control**
1926
+ Permissions are bundled into *roles* (Reader, Contributor, custom),
1927
+ and you assign roles to identities at a *scope*. Both Azure and AWS
1928
+ use this model.
1929
+
1930
+ **PIM — Privileged Identity Management** *(Azure)*
1931
+ Just-in-time elevation. You're *eligible* for a role; you have to
1932
+ *activate* it (with MFA / approval) for a limited window. Reduces
1933
+ standing admin access dramatically.
1934
+
1935
+ **IAM — Identity and Access Management** *(AWS)*
1936
+ The umbrella term for AWS users, roles, policies, and SSO permission
1937
+ sets.
1938
+
1939
+ **SPN — Service Principal** *(Azure)*
1940
+ A non-human identity (apps, pipelines). Cannot use PIM (no human to
1941
+ MFA), so it gets permanent narrowly-scoped grants.
1942
+
1943
+ **SSO — Single Sign-On**
1944
+ Users log into one identity provider and that gives them access to
1945
+ many systems without re-authenticating. AWS Identity Center and Entra
1946
+ both implement this.
1947
+
1948
+ **OIDC — OpenID Connect**
1949
+ A federation standard. Lets a workload (e.g. GitHub Actions) prove its
1950
+ identity to a cloud and assume a role *without* storing a long-lived
1951
+ secret. The biggest practical security win in modern CI/CD.
1952
+
1953
+ **MFA — Multi-Factor Authentication**
1954
+ Something you know + something you have + (optionally) something you are.
1955
+
1956
+ **Object ID**
1957
+ A unique GUID Azure assigns to each user/group/service principal. The
1958
+ *displayName* can collide; the object ID cannot.
1959
+
1960
+ ---
1961
+
1962
+ ## Policy & compliance
1963
+
1964
+ **Policy as Code**
1965
+ Compliance rules expressed as version-controlled config files, not
1966
+ checklists in a Word doc.
1967
+
1968
+ **Initiative** *(Azure Policy)*
1969
+ A bundle of policies. Easier to assign one initiative to a scope than
1970
+ 20 individual policies.
1971
+
1972
+ **SCP — Service Control Policy** *(AWS)*
1973
+ Org-wide deny rules attached to an account or organizational unit. SCPs
1974
+ *only restrict*; they cannot grant.
1975
+
1976
+ **Permissions Boundary** *(AWS)*
1977
+ A hard ceiling on what a role can do, even if a more permissive policy
1978
+ is attached. "You can never do more than this, no matter what."
1979
+
1980
+ **FedRAMP — Federal Risk and Authorization Management Program**
1981
+ US government cloud security/compliance baseline. FedRAMP
1982
+ environments usually live in isolated tenants with stricter controls.
1983
+
1984
+ **Drift**
1985
+ When real cloud state no longer matches the code's desired state.
1986
+ Detected by \`terraform plan\`; corrected by \`terraform apply\`.
1987
+
1988
+ ---
1989
+
1990
+ ## Tooling
1991
+
1992
+ **Terraform / OpenTofu**
1993
+ The dominant Infrastructure-as-Code tool. You declare *desired state*;
1994
+ Terraform calls cloud APIs to make reality match. State is recorded so
1995
+ it knows what to change next time.
1996
+
1997
+ **ARM / Bicep**
1998
+ Microsoft-native IaC for Azure. Bicep is the friendlier syntax that
1999
+ compiles down to ARM JSON.
2000
+
2001
+ **Backstage**
2002
+ Open-source developer portal originally from Spotify. \`catalog-info.yaml\`
2003
+ registers a service so engineers can discover ownership, docs, and
2004
+ lifecycle in one place.
2005
+
2006
+ **CODEOWNERS**
2007
+ A GitHub-native file that auto-requests review from owners when matching
2008
+ paths change. Combined with branch protection it becomes a hard gate.
2009
+
2010
+ ---
2011
+
2012
+ ## Process
2013
+
2014
+ **Change as Code**
2015
+ Even *how changes are made* is governed by config: PR templates,
2016
+ required reviewers, status checks. Repo controls itself.
2017
+
2018
+ **Source of Truth**
2019
+ The one trusted location that defines the correct state. In a
2020
+ governance-as-code repo, that's the repo itself.
2021
+
2022
+ **Blast Radius**
2023
+ How much can a single mistake or compromised credential affect? Lower
2024
+ is always better. Splitting roles, scoping narrowly, separating tenants
2025
+ all shrink blast radius.
2026
+ `,
2027
+ "user-management/README.md": `# User management
2028
+
2029
+ > Governance is **not** only about granting access. It's also about
2030
+ > *removing* access when someone leaves, changes roles, or shouldn't
2031
+ > have been there in the first place. Stale access is one of the most
2032
+ > common findings in real security audits.
2033
+
2034
+ This solution is the *continuous offboarding* engine.
2035
+
2036
+ ---
2037
+
2038
+ ## How it works
2039
+
2040
+ 1. The HR-controlled tenant (call it the **source of truth**) has a
2041
+ list of currently-employed identities.
2042
+ 2. R+D and SaaS tenants (e.g. SendGrid) have their own user lists that
2043
+ tend to drift — people get added, rarely removed.
2044
+ 3. A nightly GitHub Actions cron (\`.github/workflows/user-offboarding.yml\`)
2045
+ runs the script in \`scripts/offboard-users.ps1\`:
2046
+ - pulls users from each non-source tenant
2047
+ - diffs against the source of truth
2048
+ - removes anyone present in the side tenant but missing in the source
2049
+ 4. **Safety rail**: if the diff would delete more than \`MAX_DELETIONS\`
2050
+ users in one run, the script aborts loudly. Forces a human to
2051
+ investigate before mass deletion.
2052
+ 5. **Dry-run mode**: manual dispatch defaults to \`dry_run=true\`, so
2053
+ you can review what *would* be deleted before doing it for real.
2054
+
2055
+ ---
2056
+
2057
+ ## Why it's part of governance, not just IT ops
2058
+
2059
+ - Provable, version-controlled deletion logic (auditors love this).
2060
+ - Tenants stay aligned without anyone having to remember.
2061
+ - Same review/CODEOWNERS gate as access *grants* — symmetry matters.
2062
+ `,
2063
+ "user-management/config/tenants.json": `{
2064
+ "sourceOfTruthTenant": {
2065
+ "name": "corp",
2066
+ "tenantId": "00000000-0000-0000-0000-000000000001",
2067
+ "description": "HR-controlled. Authoritative list of current employees."
2068
+ },
2069
+ "managedTenants": [
2070
+ {
2071
+ "name": "rd-test",
2072
+ "tenantId": "00000000-0000-0000-0000-000000000002",
2073
+ "removeIfMissingFromSource": true
2074
+ },
2075
+ {
2076
+ "name": "rd-prod",
2077
+ "tenantId": "00000000-0000-0000-0000-000000000003",
2078
+ "removeIfMissingFromSource": true
2079
+ }
2080
+ ],
2081
+ "saasTargets": [
2082
+ {
2083
+ "name": "sendgrid",
2084
+ "kind": "sendgrid",
2085
+ "removeIfMissingFromSource": true
2086
+ }
2087
+ ]
2088
+ }
2089
+ `,
2090
+ "user-management/scripts/offboard-users.ps1": `# Cross-tenant offboarding script.
2091
+ #
2092
+ # Reads config/tenants.json, diffs each managed tenant against the
2093
+ # source-of-truth tenant, and removes users who are missing from source.
2094
+ #
2095
+ # Designed to be safe by default:
2096
+ # - DRY_RUN=true -> log only, no deletes
2097
+ # - MAX_DELETIONS guard rail -> aborts if diff is too large
2098
+ # - Each tenant runs independently -> one tenant's failure doesn't skip others
2099
+
2100
+ #Requires -Version 7.2
2101
+ [CmdletBinding()]
2102
+ param(
2103
+ [string]$ConfigPath = "$PSScriptRoot/../config/tenants.json",
2104
+ [int] $MaxDeletions = [int]($env:MAX_DELETIONS ?? 10),
2105
+ [bool] $DryRun = [bool]::Parse(($env:DRY_RUN ?? "true"))
2106
+ )
2107
+
2108
+ $ErrorActionPreference = "Stop"
2109
+ Set-StrictMode -Version Latest
2110
+
2111
+ # Real implementations would import Microsoft.Graph and a SendGrid SDK.
2112
+ # We stub the calls so this file is readable as a learning artifact.
2113
+ function Get-TenantUsers([string]$TenantId) {
2114
+ Write-Host " [stub] Get-TenantUsers $TenantId"
2115
+ return @() # array of @{ upn = '...'; objectId = '...' }
2116
+ }
2117
+
2118
+ function Remove-TenantUser([string]$TenantId, [string]$ObjectId) {
2119
+ Write-Host " [stub] Remove-TenantUser $TenantId $ObjectId"
2120
+ }
2121
+
2122
+ function Remove-SendGridUser([string]$Email) {
2123
+ Write-Host " [stub] Remove-SendGridUser $Email"
2124
+ }
2125
+
2126
+ # ── 1. Load config ────────────────────────────────────────────────────
2127
+ $config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
2128
+ $source = Get-TenantUsers -TenantId $config.sourceOfTruthTenant.tenantId
2129
+ $sourceSet = @{}
2130
+ foreach ($u in $source) { $sourceSet[$u.upn.ToLower()] = $true }
2131
+
2132
+ Write-Host "Source-of-truth tenant has $($source.Count) users."
2133
+
2134
+ # ── 2. Diff each managed tenant ───────────────────────────────────────
2135
+ foreach ($tenant in $config.managedTenants) {
2136
+ Write-Host "\`n=== Tenant: $($tenant.name) ($($tenant.tenantId)) ==="
2137
+ if (-not $tenant.removeIfMissingFromSource) {
2138
+ Write-Host " Skipped (removeIfMissingFromSource=false)"
2139
+ continue
2140
+ }
2141
+
2142
+ $managed = Get-TenantUsers -TenantId $tenant.tenantId
2143
+ $toRemove = @($managed | Where-Object { -not $sourceSet.ContainsKey($_.upn.ToLower()) })
2144
+ Write-Host " Would remove: $($toRemove.Count) user(s)."
2145
+
2146
+ if ($toRemove.Count -gt $MaxDeletions) {
2147
+ throw "ABORT: $($toRemove.Count) deletions exceed MaxDeletions=$MaxDeletions for tenant $($tenant.name). Investigate before re-running."
2148
+ }
2149
+
2150
+ foreach ($u in $toRemove) {
2151
+ if ($DryRun) {
2152
+ Write-Host " [dry-run] would remove $($u.upn)"
2153
+ } else {
2154
+ Remove-TenantUser -TenantId $tenant.tenantId -ObjectId $u.objectId
2155
+ Write-Host " removed $($u.upn)"
2156
+ }
2157
+ }
2158
+ }
2159
+
2160
+ # ── 3. SaaS targets (e.g. SendGrid) ───────────────────────────────────
2161
+ foreach ($saas in $config.saasTargets) {
2162
+ if ($saas.kind -eq "sendgrid" -and -not $DryRun) {
2163
+ # Real impl: list SendGrid users, diff against $sourceSet, call DELETE.
2164
+ Write-Host "\`nSendGrid offboarding stub — implement Get/Delete via SendGrid API."
2165
+ }
2166
+ }
2167
+
2168
+ Write-Host "\`nDone. DryRun=$DryRun"
2169
+ `,
2170
+ };
2171
+
2172
+ export const GOVERNANCE_GHA_LAB: GithubActionsLabWorkspace = {
2173
+ version: 1,
2174
+ label: "Platform Governance Template",
2175
+ activeFile: ".github/CODEOWNERS",
2176
+ defaultEvent: "pull_request",
2177
+ defaultWorkflow: ".github/workflows/pr_validations.yml",
2178
+ files: GOVERNANCE_FILES,
2179
+ ghOrg: DEFAULT_GH_LAB_ORG,
2180
+ rulesets: DEFAULT_GH_LAB_RULESETS,
2181
+ pullRequest: DEFAULT_GH_LAB_PULL_REQUEST,
2182
+ };
2183
+
492
2184
  export const DEFAULT_GHA_LAB: GithubActionsLabWorkspace = {
493
2185
  version: 1,
494
2186
  label: "GitHub Lab Playground",
@@ -910,6 +2602,7 @@ function cloneGhLabPullRequest(
910
2602
  reviews,
911
2603
  ...(lastCheckRun ? { lastCheckRun } : {}),
912
2604
  ...(typeof pr.title === "string" && pr.title ? { title: pr.title } : {}),
2605
+ ...(typeof pr.body === "string" ? { body: pr.body } : {}),
913
2606
  };
914
2607
  }
915
2608