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.
- package/package.json +1 -1
- package/template/client/src/codeowners.ts +120 -0
- package/template/client/src/components/ChatView.tsx +18 -7
- package/template/client/src/components/GithubActionsLabModal.tsx +80 -39
- package/template/client/src/components/LabsPanel.tsx +7 -0
- package/template/client/src/components/PullRequestPanel.tsx +125 -0
- package/template/client/src/components/SettingsPanel.tsx +4 -1
- package/template/client/src/githubActionsLab.ts +1693 -0
- package/template/client/src/index.css +71 -0
- package/template/client/src/types.ts +6 -0
- package/template/cockpit.json +1 -1
|
@@ -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
|
|