├── .github └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── Makefile ├── README.md ├── build └── common.mk ├── cmd └── pulumi-analyzer-policy-opa │ ├── analyzer.go │ ├── eval.go │ ├── main.go │ ├── policy.go │ └── serve.go ├── examples ├── app │ ├── .gitignore │ ├── Pulumi.yaml │ ├── README.md │ ├── index.ts │ ├── package.json │ └── tsconfig.json └── policy-kubernetes │ ├── PulumiPolicy.yaml │ └── kubernetes.rego ├── go.mod ├── go.sum └── scripts └── get-version /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | permissions: write-all # Equivalent to default permissions plus id-token: write 2 | name: release 3 | on: 4 | push: 5 | tags: ["v*.[0-99]"] 6 | env: 7 | AWS_REGION: us-west-2 8 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 9 | ESC_ACTION_OIDC_AUTH: true 10 | ESC_ACTION_OIDC_ORGANIZATION: pulumi 11 | ESC_ACTION_OIDC_REQUESTED_TOKEN_TYPE: urn:pulumi:token-type:access_token:organization 12 | ESC_ACTION_ENVIRONMENT: imports/github-secrets 13 | ESC_ACTION_EXPORT_ENVIRONMENT_VARIABLES: false 14 | 15 | jobs: 16 | goreleaser: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Fetch secrets from ESC 20 | id: esc-secrets 21 | uses: pulumi/esc-action@v1 22 | - name: Checkout 23 | uses: actions/checkout@v2 24 | - name: Unshallow clone 25 | run: git fetch --prune --unshallow --tags 26 | - name: Install Go 1.14 27 | uses: actions/setup-go@v2 28 | with: 29 | go-version: '1.14.x' 30 | - name: Configure AWS Credentials 31 | uses: aws-actions/configure-aws-credentials@v1 32 | with: 33 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 34 | aws-region: us-east-2 35 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 36 | role-duration-seconds: 3600 37 | role-external-id: upload-pulumi-release 38 | role-session-name: pulumi-policy-opa@githubActions 39 | role-to-assume: ${{ steps.esc-secrets.outputs.AWS_UPLOAD_ROLE_ARN }} 40 | - name: Goreleaser publish 41 | uses: goreleaser/goreleaser-action@v1 42 | with: 43 | version: latest 44 | args: release --rm-dist 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | - GO111MODULE=on 8 | goos: 9 | - darwin 10 | - windows 11 | - linux 12 | goarch: 13 | - amd64 14 | binary: pulumi-analyzer-policy-opa 15 | main: ./cmd/pulumi-analyzer-policy-opa/ 16 | archives: 17 | - id: archive 18 | name_template: "{{ .Binary }}-{{ .Tag }}-{{ .Os }}-{{ .Arch }}" 19 | blobs: 20 | - bucket: get.pulumi.com 21 | folder: releases/plugins/ 22 | ids: 23 | - archive 24 | provider: s3 25 | region: us-west-2 26 | snapshot: 27 | name_template: "{{ .Tag }}-SNAPSHOT" 28 | changelog: 29 | skip: true 30 | release: 31 | prerelease: auto 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT_NAME := Pulumi Policy OPA Bridge 2 | include build/common.mk 3 | 4 | PROJECT := github.com/pulumi/pulumi-policy-opa/cmd/pulumi-analyzer-policy-opa 5 | GOPKGS := $(shell go list ./... | grep -v /vendor/) 6 | TESTPARALLELISM := 10 7 | 8 | build:: 9 | go build ${PROJECT} 10 | 11 | install:: 12 | go install ${PROJECT} 13 | 14 | lint:: 15 | golangci-lint run 16 | 17 | test_all:: 18 | $(GO_TEST) ${GOPKGS} 19 | 20 | .PHONY: check_clean_worktree 21 | check_clean_worktree: 22 | $$(go env GOPATH)/src/github.com/pulumi/scripts/ci/check-worktree-is-clean.sh 23 | 24 | # The travis_* targets are entrypoints for CI. 25 | .PHONY: travis_cron travis_push travis_pull_request travis_api 26 | travis_cron: all 27 | travis_push: all check_clean_worktree only_test 28 | travis_pull_request: all 29 | travis_api: all 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pulumi Open Policy Agent (OPA) Bridge for CrossGuard 2 | 3 | This project allows Open Policy Agent (OPA) rules to be run in the context of Pulumi's policy system, CrossGuard. 4 | 5 | ## How it works 6 | 7 | Pulumi can enforce policies during a deployment. This includes during a "preview" -- before a deployment is attempted -- 8 | in addition to afterwards -- when certain other properties are known. 9 | 10 | The OPA integration implements the Pulumi plugin interface for policies. Unlike Pulumi's standard approach to 11 | implementing policy rules using [an SDK in a general purpose language](https://github.com/pulumi/pulumi-policy) 12 | this bridge lets you author Pulumi Crossguard policies using OPA and `.rego` syntax. 13 | 14 | ## How to use OPA with Pulumi CrossGuard 15 | 16 | First, install the OPA policy analyzer plugin. 17 | 18 | ``` 19 | $ pulumi plugin install analyzer policy-opa v0.0.2 20 | [analyzer plugin policy-opa-0.0.2] installing 21 | Downloading plugin: 6.11 MiB / 6.11 MiB [===========================] 100.00% 0s 22 | Moving plugin... done. 23 | ``` 24 | 25 | You can now use OPA policy packs. Create a folder that contains two files - a `PulumiPolicy.yaml` and one or more `.rego` files. 26 | 27 | ``` 28 | $ cat PulumiPolicy.yaml 29 | description: A minimal Policy Pack for Kubernetes using OPA. 30 | runtime: opa 31 | 32 | $ cat labels.rego 33 | package kubernetes 34 | 35 | name = input.metadata.name 36 | 37 | labels { 38 | input.metadata.labels["app.kubernetes.io/name"] 39 | input.metadata.labels["app.kubernetes.io/instance"] 40 | input.metadata.labels["app.kubernetes.io/version"] 41 | input.metadata.labels["app.kubernetes.io/component"] 42 | input.metadata.labels["app.kubernetes.io/part-of"] 43 | input.metadata.labels["app.kubernetes.io/managed-by"] 44 | } 45 | 46 | deny[msg] { 47 | input.kind = "Deployment" 48 | not labels 49 | msg = sprintf("%s must include Kubernetes recommended labels: https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels ", [name]) 50 | } 51 | ``` 52 | 53 | You can now run an update on a Pulumi program locally using `pulumi up --policy-pack ` passing the path to the folder you created in the previous step. 54 | 55 | ``` 56 | $ pulumi up --policy-pack ../policy-kubernetes 57 | Previewing update (dev): 58 | Type Name Plan Info 59 | + pulumi:pulumi:Stack simple-kubernetes-dev create 1 error 60 | + └─ kubernetes:apps:Deployment nginx create 61 | 62 | Diagnostics: 63 | pulumi:pulumi:Stack (simple-kubernetes-dev): 64 | error: preview failed 65 | 66 | Policy Violations: 67 | [mandatory] kubernetes v0.0.1 deny (nginx: kubernetes:apps/v1:Deployment) 68 | nginx-me0llhgr must include Kubernetes recommended labels: https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels 69 | ``` 70 | 71 | Note that the policy above was implemented in `labels.rego` using the Rego language, but applied to the deployment of a Pulumi program written in TypeScript. Note also that the policy was run *before* the resource was deployed, and failed the preview stage. This allows OPA policies to be enforced very early in the development and deployment process - close to the developers creating the infrastructure - allowing for a quicker security and policy feedback loop for the cloud engineering team. 72 | 73 | This policy pack can also be [published to the Pulumi Service](https://www.pulumi.com/docs/get-started/crossguard/enforcing-a-policy-pack/) so that it will be enforced across your Organization. 74 | 75 | ``` 76 | $ pulumi policy publish 77 | Obtaining policy metadata from policy plugin 78 | Compressing policy pack 79 | Uploading policy pack to Pulumi service 80 | Publishing "kubernetes" to "myorg" 81 | Published as version 1 82 | 83 | Permalink: https://app.pulumi.com/myorg/policypacks/kubernetes/1 84 | ``` 85 | 86 | For more details on working with Policy as Code in Pulumi, see the CrossGuard documentation at https://www.pulumi.com/docs/guides/crossguard/. 87 | -------------------------------------------------------------------------------- /build/common.mk: -------------------------------------------------------------------------------- 1 | # Copyright 2016-2018, Pulumi Corporation. All rights reserved. 2 | 3 | # common.mk provides most of the scalfholding for our build system. It 4 | # provides default targets for each project we want to build. 5 | # 6 | # The default targets we use are: 7 | # 8 | # - ensure: restores and dependencies needed for the build from 9 | # remote sources (e.g dep ensure or yarn install) 10 | # 11 | # - build: builds a project but does not install it. In the case of 12 | # go code, this usually means running go install (which 13 | # would place them in `GOBIN`, but not `PULUMI_ROOT` 14 | # 15 | # - install: copies the bits we plan to ship into a layout in 16 | # `PULUMI_ROOT` that looks like what a customer would get 17 | # when they download and install Pulumi. For JavaScript 18 | # projects, installing also runs yarn link to register 19 | # this package, so that other projects can depend on it. 20 | # 21 | # - lint: runs relevent linters for the project 22 | # 23 | # - test_fast: runs the fast tests for a project. These are often 24 | # go unit tests or javascript unit tests, they should 25 | # complete quickly, as we expect developers to run them 26 | # fequently as part of their "inner loop" development. 27 | # 28 | # - test_all: runs all of test_fast and then runs additional testing, 29 | # which may take longer (some times a lot longer!). These 30 | # are often integration tests which will use `pulumi` to 31 | # deploy example Pulumi projects, creating cloud 32 | # resources along the way. 33 | # 34 | # In addition, we have a few higher level targets that just depend on 35 | # these targets: 36 | # 37 | # - only_build: this target runs build and install targets 38 | # 39 | # - only_test: this target runs the list and test_all targets 40 | # (test_all itself runs test_fast) 41 | # 42 | # - default: this is the target that is run by default when no 43 | # arguments are passed to make, it runs the build, lint, 44 | # install and test_fast targets 45 | # 46 | # - core: this target behaves like `default` except for the case 47 | # where a project declares SUB_PROJECTS (see a discussion on 48 | # that later). In that case, building `core` target does not 49 | # build sub projects. 50 | # 51 | # - all: this target runs build, lint, install and test_all (which 52 | # itself runs test_fast). 53 | # 54 | # Before including this makefile, a project may define some values 55 | # that this makefile understands: 56 | # 57 | # - PROJECT_NAME: If set, make default and make all will print a banner 58 | # with the project name when they are built. 59 | # 60 | # - SUB_PROJECTS: If set, each item in the list is treated as a path 61 | # to another project (relative to the directory of the 62 | # main Makefile) which should be built as well. When 63 | # this happens, the default and all targets first 64 | # build the default or all target of each child 65 | # project. For each subproject we also create targets 66 | # with our standard names, prepended by the target 67 | # name and an underscore, which just calls Make for 68 | # that specific target. These can be handy targets to 69 | # build explicitly on the command line from time to 70 | # time. 71 | # 72 | # - NODE_MODULE_NAME: If set, an install target will be auto-generated 73 | # that installs the module to 74 | # $(PULUMI_ROOT)/node_modules/$(NODE_MODULE_NAME) 75 | # 76 | # This Makefile also provides some convience methods: 77 | # 78 | # STEP_MESSAGE is a macro that can be invoked with `$(call 79 | # STEP_MESSAGE)` and it will print the name of the current target (in 80 | # green text) to the console. All the targets provided by this makefile 81 | # do that by default. 82 | # 83 | # The ensure target also provides some default behavior, detecting if 84 | # there is a Gopkg.toml or package.json file in the current folder and 85 | # if so calling dep ensure -v or yarn install. This behavior means that 86 | # projects will not often need to augment the ensure target. 87 | # 88 | # Unlike the other leaf targets, ensure will call the ensure target on 89 | # any sub-projects. 90 | # 91 | # Importing common.mk should be the first thing your Makefile does, after 92 | # optionally setting SUB_PROJECTS, PROJECT_NAME and NODE_MODULE_NAME. 93 | SHELL := /bin/bash 94 | .SHELLFLAGS := -ec 95 | 96 | STEP_MESSAGE = @echo -e "\033[0;32m$(shell echo '$@' | tr a-z A-Z | tr '_' ' '):\033[0m" 97 | 98 | # Our install targets place items item into $PULUMI_ROOT, if it's 99 | # unset, default to /opt/pulumi. 100 | ifeq ($(PULUMI_ROOT),) 101 | PULUMI_ROOT:=/opt/pulumi 102 | endif 103 | 104 | PULUMI_BIN := $(PULUMI_ROOT)/bin 105 | PULUMI_NODE_MODULES := $(PULUMI_ROOT)/node_modules 106 | 107 | GO_TEST_FAST = go test -short -v -count=1 -cover -timeout 2h -parallel ${TESTPARALLELISM} 108 | GO_TEST = go test -v -count=1 -cover -timeout 2h -parallel ${TESTPARALLELISM} 109 | 110 | .PHONY: default all ensure only_build only_test build lint install test_all core 111 | 112 | # ensure that `default` is the target that is run when no arguments are passed to make 113 | default:: 114 | 115 | PYTHON ?= python3 116 | PIP ?= pip3 117 | 118 | # If there are sub projects, our default, all, and ensure targets will 119 | # recurse into them. 120 | ifneq ($(SUB_PROJECTS),) 121 | only_build:: $(SUB_PROJECTS:%=%_only_build) 122 | only_test:: $(SUB_PROJECTS:%=%_only_test) 123 | only_test_fast:: $(SUB_PROJECTS:%=%_only_test_fast) 124 | default:: $(SUB_PROJECTS:%=%_default) 125 | all:: $(SUB_PROJECTS:%=%_all) 126 | ensure:: $(SUB_PROJECTS:%=%_ensure) 127 | endif 128 | 129 | # `core` is like `default` except it does not build sub projects. 130 | core:: build lint install test_fast 131 | 132 | # If $(PROJECT_NAME) has been set, have our default and all targets 133 | # print a nice banner. 134 | ifneq ($(PROJECT_NAME),) 135 | default:: 136 | @echo -e "\033[1;37m$(shell echo '$(PROJECT_NAME)' | sed -e 's/./=/g')\033[1;37m" 137 | @echo -e "\033[1;37m$(PROJECT_NAME)\033[1;37m" 138 | @echo -e "\033[1;37m$(shell echo '$(PROJECT_NAME)' | sed -e 's/./=/g')\033[1;37m" 139 | all:: 140 | @echo -e "\033[1;37m$(shell echo '$(PROJECT_NAME)' | sed -e 's/./=/g')\033[1;37m" 141 | @echo -e "\033[1;37m$(PROJECT_NAME)\033[1;37m" 142 | @echo -e "\033[1;37m$(shell echo '$(PROJECT_NAME)' | sed -e 's/./=/g')\033[1;37m" 143 | endif 144 | 145 | default:: build install lint test_fast 146 | all:: build install lint test_all 147 | 148 | ensure:: 149 | $(call STEP_MESSAGE) 150 | @if [ -e 'Gopkg.toml' ]; then echo "dep ensure -v"; dep ensure -v; \ 151 | elif [ -e 'go.mod' ]; then echo "GO111MODULE=on go mod vendor"; GO111MODULE=on go mod vendor; fi 152 | @if [ -e 'package.json' ]; then echo "yarn install"; yarn install; fi 153 | 154 | build:: 155 | $(call STEP_MESSAGE) 156 | 157 | lint:: 158 | $(call STEP_MESSAGE) 159 | 160 | test_fast:: 161 | $(call STEP_MESSAGE) 162 | 163 | install:: 164 | $(call STEP_MESSAGE) 165 | @mkdir -p $(PULUMI_BIN) 166 | @mkdir -p $(PULUMI_NODE_MODULES) 167 | 168 | test_all:: 169 | $(call STEP_MESSAGE) 170 | 171 | ifneq ($(NODE_MODULE_NAME),) 172 | install:: 173 | [ ! -e "$(PULUMI_NODE_MODULES)/$(NODE_MODULE_NAME)" ] || rm -rf "$(PULUMI_NODE_MODULES)/$(NODE_MODULE_NAME)" 174 | mkdir -p "$(PULUMI_NODE_MODULES)/$(NODE_MODULE_NAME)" 175 | cp -r bin/. "$(PULUMI_NODE_MODULES)/$(NODE_MODULE_NAME)" 176 | cp package.json "$(PULUMI_NODE_MODULES)/$(NODE_MODULE_NAME)" 177 | cp yarn.lock "$(PULUMI_NODE_MODULES)/$(NODE_MODULE_NAME)" 178 | rm -rf "$(PULUMI_NODE_MODULES)/$(NODE_MODULE_NAME)/node_modules" 179 | cd "$(PULUMI_NODE_MODULES)/$(NODE_MODULE_NAME)" && \ 180 | yarn install --offline --production && \ 181 | (yarn unlink > /dev/null 2>&1 || true) && \ 182 | yarn link 183 | endif 184 | 185 | only_build:: build install 186 | only_test:: lint test_all 187 | only_test_fast:: lint test_fast 188 | 189 | # Generate targets for each sub project. This project's default and 190 | # all targets will depend on the sub project's targets, and the 191 | # individual targets for sub projects are added as a convience when 192 | # invoking make from the command line 193 | ifneq ($(SUB_PROJECTS),) 194 | $(SUB_PROJECTS:%=%_default): 195 | @$(MAKE) -C ./$(@:%_default=%) default 196 | $(SUB_PROJECTS:%=%_all): 197 | @$(MAKE) -C ./$(@:%_all=%) all 198 | $(SUB_PROJECTS:%=%_ensure): 199 | @$(MAKE) -C ./$(@:%_ensure=%) ensure 200 | $(SUB_PROJECTS:%=%_build): 201 | @$(MAKE) -C ./$(@:%_build=%) build 202 | $(SUB_PROJECTS:%=%_lint): 203 | @$(MAKE) -C ./$(@:%_lint=%) lint 204 | $(SUB_PROJECTS:%=%_test_fast): 205 | @$(MAKE) -C ./$(@:%_test_fast=%) test_fast 206 | $(SUB_PROJECTS:%=%_install): 207 | @$(MAKE) -C ./$(@:%_install=%) install 208 | $(SUB_PROJECTS:%=%_test_all): 209 | @$(MAKE) -C ./$(@:%_test_all=%) test_all 210 | $(SUB_PROJECTS:%=%_only_build): 211 | @$(MAKE) -C ./$(@:%_only_build=%) only_build 212 | $(SUB_PROJECTS:%=%_only_test): 213 | @$(MAKE) -C ./$(@:%_only_test=%) only_test 214 | $(SUB_PROJECTS:%=%_only_test_fast): 215 | @$(MAKE) -C ./$(@:%_only_test_fast=%) only_test_fast 216 | endif 217 | 218 | # As a convinece, we provide a format target that folks can build to 219 | # run go fmt over all the go code in their tree. 220 | .PHONY: format 221 | format: 222 | find . -iname "*.go" -not -path "./vendor/*" | xargs gofmt -s -w 223 | -------------------------------------------------------------------------------- /cmd/pulumi-analyzer-policy-opa/analyzer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | pbempty "github.com/golang/protobuf/ptypes/empty" 7 | pbstruct "github.com/golang/protobuf/ptypes/struct" 8 | 9 | "github.com/pulumi/pulumi/pkg/resource/provider" 10 | pulumirpc "github.com/pulumi/pulumi/sdk/proto/go" 11 | ) 12 | 13 | const Version = "0.0.1" // TODO: load this from a linker-generated version. 14 | 15 | // analyzer implements the gRPC interface needed to plug into Pulumi as a policy analyzer. 16 | type analyzer struct { 17 | host *provider.HostClient 18 | pack *policyPack 19 | e *evaler 20 | } 21 | 22 | func NewAnalyzer(host *provider.HostClient, pack *policyPack, e *evaler) pulumirpc.AnalyzerServer { 23 | return &analyzer{ 24 | host: host, 25 | pack: pack, 26 | e: e, 27 | } 28 | } 29 | 30 | func (a *analyzer) Analyze(ctx context.Context, req *pulumirpc.AnalyzeRequest) (*pulumirpc.AnalyzeResponse, error) { 31 | var diagnostics []*pulumirpc.AnalyzeDiagnostic 32 | 33 | // Run the policy pack against this object's metadata. 34 | // TODO: to attain rule compatibility with OPA rules written for, say, the Kubernetes Admission 35 | // Controller, there is a very different schem we would need to follow. It's possible we should 36 | // make the schema translation pluggable and customizable for certain policy packs and/or providers. 37 | obj := pbStructToGo(req.Properties) 38 | results, err := a.e.evalPolicyPack(ctx, a.pack, obj) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | // Translate the policy results into the appropriate analyzer RPC data structures. 44 | for _, result := range results { 45 | var level pulumirpc.EnforcementLevel 46 | if result.level == advisoryRule { 47 | level = pulumirpc.EnforcementLevel_ADVISORY 48 | } else { 49 | level = pulumirpc.EnforcementLevel_MANDATORY 50 | } 51 | diagnostics = append(diagnostics, &pulumirpc.AnalyzeDiagnostic{ 52 | PolicyName: result.rule, 53 | PolicyPackName: result.pack, 54 | PolicyPackVersion: Version, 55 | Message: result.msg, 56 | Urn: req.Urn, 57 | EnforcementLevel: level, 58 | // TODO: Description, Tags, EnforcementLevel 59 | }) 60 | } 61 | 62 | return &pulumirpc.AnalyzeResponse{Diagnostics: diagnostics}, nil 63 | } 64 | 65 | func (a *analyzer) AnalyzeStack(ctx context.Context, req *pulumirpc.AnalyzeStackRequest) (*pulumirpc.AnalyzeResponse, error) { 66 | // TODO: surface the complete set of resources to the OPA rule, perhaps as a different property. 67 | // We don't bother to re-run the rules here since we already analyzed all of them. 68 | return &pulumirpc.AnalyzeResponse{}, nil 69 | } 70 | 71 | func (a *analyzer) GetAnalyzerInfo(ctx context.Context, req *pbempty.Empty) (*pulumirpc.AnalyzerInfo, error) { 72 | var policies []*pulumirpc.PolicyInfo 73 | for _, pol := range a.pack.Policies { 74 | policies = append(policies, &pulumirpc.PolicyInfo{ 75 | Name: pol.Name, 76 | DisplayName: pol.DisplayName, 77 | Description: pol.Description, 78 | Message: pol.Message, 79 | EnforcementLevel: pulumirpc.EnforcementLevel(pol.Level), 80 | }) 81 | } 82 | return &pulumirpc.AnalyzerInfo{ 83 | Name: a.pack.Name, 84 | DisplayName: a.pack.DisplayName, 85 | Policies: policies, 86 | }, nil 87 | } 88 | 89 | func (a *analyzer) GetPluginInfo(ctx context.Context, req *pbempty.Empty) (*pulumirpc.PluginInfo, error) { 90 | return &pulumirpc.PluginInfo{Version: Version}, nil 91 | } 92 | 93 | // pbStructToGo converts a Protobuf struct to a Go map. 94 | func pbStructToGo(s *pbstruct.Struct) map[string]interface{} { 95 | if s == nil { 96 | return nil 97 | } 98 | 99 | m := make(map[string]interface{}) 100 | for k, v := range s.Fields { 101 | m[k] = pbValueToGo(v) 102 | } 103 | return m 104 | } 105 | 106 | // structValueToIface converts a Protobuf value to its Go equivalent. 107 | func pbValueToGo(v *pbstruct.Value) interface{} { 108 | switch k := v.Kind.(type) { 109 | case *pbstruct.Value_BoolValue: 110 | return k.BoolValue 111 | case *pbstruct.Value_ListValue: 112 | var a []interface{} 113 | for _, e := range k.ListValue.Values { 114 | a = append(a, pbValueToGo(e)) 115 | } 116 | return a 117 | case *pbstruct.Value_NullValue: 118 | return nil 119 | case *pbstruct.Value_NumberValue: 120 | return k.NumberValue 121 | case *pbstruct.Value_StringValue: 122 | return k.StringValue 123 | case *pbstruct.Value_StructValue: 124 | return pbStructToGo(k.StructValue) 125 | default: 126 | return nil 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /cmd/pulumi-analyzer-policy-opa/eval.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/open-policy-agent/opa/ast" 8 | "github.com/open-policy-agent/opa/rego" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type evaler struct { 13 | c *ast.Compiler 14 | } 15 | 16 | func (e *evaler) evalPolicyPack(ctx context.Context, pack *policyPack, input interface{}) ([]evalPolicyResult, error) { 17 | var results []evalPolicyResult 18 | 19 | for _, rule := range pack.Policies { 20 | // Build a rego object that can be evaluated. 21 | robj := rego.New( 22 | rego.Query(fmt.Sprintf("data.%s.%s", pack.Name, rule.Name)), 23 | rego.Compiler(e.c), 24 | rego.Input(input), 25 | ) 26 | 27 | resultSet, err := robj.Eval(ctx) 28 | if err != nil { 29 | return nil, errors.Wrapf(err, "evaluating rule %s.%s", pack.Name, rule.Name) 30 | } 31 | 32 | for _, result := range resultSet { 33 | for _, expr := range result.Expressions { 34 | if ae, ok := expr.Value.([]interface{}); ok && len(ae) > 0 { 35 | for _, v := range ae { 36 | results = append(results, evalPolicyResult{ 37 | pack: pack.Name, 38 | rule: rule.Name, 39 | msg: v.(string), 40 | level: rule.Level, 41 | }) 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | return results, nil 49 | } 50 | 51 | type evalPolicyResult struct { 52 | pack string 53 | rule string 54 | msg string 55 | level enforcementLevel 56 | } 57 | -------------------------------------------------------------------------------- /cmd/pulumi-analyzer-policy-opa/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, Pulumi Corporation. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "encoding/json" 19 | "flag" 20 | "os" 21 | 22 | "github.com/pulumi/pulumi/pkg/util/cmdutil" 23 | "github.com/pulumi/pulumi/pkg/util/contract" 24 | ) 25 | 26 | func main() { 27 | // Enable overriding the rules location and/or dumping plugin info. 28 | flags := flag.NewFlagSet("tf-provider-flags", flag.ContinueOnError) 29 | dumpInfo := flags.Bool("get-plugin-info", false, "dump plugin info as JSON to stdout") 30 | contract.IgnoreError(flags.Parse(os.Args[1:])) 31 | args := flags.Args() 32 | 33 | pack, e, err := loadPolicyPack(args[1]) 34 | if err != nil { 35 | cmdutil.ExitError(err.Error()) 36 | } 37 | 38 | if *dumpInfo { 39 | if err := json.NewEncoder(os.Stdout).Encode(pack); err != nil { 40 | cmdutil.ExitError(err.Error()) 41 | } 42 | os.Exit(0) 43 | } 44 | 45 | if err := Serve(pack, e, args); err != nil { 46 | cmdutil.ExitError(err.Error()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /cmd/pulumi-analyzer-policy-opa/policy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/open-policy-agent/opa/ast" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | // Rego modules contain rules, some of which have prefixes. Only those with the appropriate 15 | // prefix will be considered rules for evaluation -- all others are used as library routines. 16 | var ( 17 | denyRulePrefix = regexp.MustCompile("^(deny|violation)(_[a-zA-Z]+)*$") 18 | warnRulePrefix = regexp.MustCompile("^warn(_[a-zA-Z]+)*$") 19 | ) 20 | 21 | // loadPolicyPack loads the metadata about a pack and its policies from a directory containing OPA *.rego files. 22 | func loadPolicyPack(dir string) (*policyPack, *evaler, error) { 23 | // First open the manifest file to learn more about the pack. 24 | // TODO: we need to do this in order to provide metadata about the package itself, like its name, 25 | // description, and so on. The idea here is to just put a PulumiPolicy.yaml inside the rules/ directory. 26 | 27 | // Next gather up all the OPA rego files to run and prepare to compile them. 28 | modules := make(map[string]string) 29 | if err := filepath.Walk(dir, func(path string, info os.FileInfo, fileErr error) error { 30 | if fileErr != nil { 31 | return errors.Wrapf(fileErr, "searching for policies in %s", dir) 32 | } else if !info.IsDir() && filepath.Ext(path) == ".rego" { 33 | // Read the program into memory so we can compile it below. 34 | b, err := ioutil.ReadFile(path) 35 | if err != nil { 36 | return errors.Wrapf(err, "reading policy %s", path) 37 | } 38 | 39 | // Take the relative path from the target rules dir, remove the prefix, and use that as the rule name. 40 | name, err := filepath.Rel(dir, path) 41 | if err != nil { 42 | return errors.Wrapf(err, "normalizing path (%s, %s)", dir, path) 43 | } 44 | dotIndex := strings.LastIndex(name, ".") 45 | modules[name[:dotIndex]] = string(b) 46 | } 47 | return nil 48 | }); err != nil { 49 | return nil, nil, err 50 | } 51 | 52 | // Compile all of the policy files so we can error out early if there are problems. 53 | compiler, err := ast.CompileModules(modules) 54 | if err != nil { 55 | return nil, nil, errors.Wrapf(err, "policy compilation failed") 56 | } 57 | 58 | // Buld up a list of rules. 59 | var packName string 60 | var policies []*policyRule 61 | for name, module := range compiler.Modules { 62 | // First determine the package name. This should match for all rules. 63 | pkg := module.Package.String() 64 | if strings.Index(pkg, "package ") != 0 { 65 | return nil, nil, errors.Errorf("malformed package name, expected 'package' prefix: %s", pkg) 66 | } 67 | pkg = pkg[len("package "):] 68 | if packName == "" { 69 | packName = pkg 70 | } else if packName != pkg { 71 | return nil, nil, errors.Errorf("unexpected package name differences: got %s, expected %s", pkg, packName) 72 | } 73 | 74 | // Next go through all rules and tease them apart, skipping duplicates. 75 | existing := make(map[string]bool) 76 | for _, rule := range module.Rules { 77 | ruleName := rule.Head.Name.String() 78 | 79 | // Only process those that are legitimate errors or warnings. Other "rules" are 80 | // actually just libraries that can be used as routines in authoring other rules. 81 | var level enforcementLevel 82 | if denyRulePrefix.MatchString(ruleName) { 83 | level = mandatoryRule 84 | } else if warnRulePrefix.MatchString(ruleName) { 85 | level = advisoryRule 86 | } else { 87 | continue // skip 88 | } 89 | 90 | if _, has := existing[ruleName]; !has { 91 | existing[ruleName] = true 92 | policies = append(policies, &policyRule{ 93 | Name: ruleName, 94 | DisplayName: name, 95 | // TODO: Description, Message 96 | Level: level, 97 | }) 98 | } 99 | } 100 | } 101 | 102 | // Create the resulting policy pack metadata. 103 | pack := &policyPack{ 104 | Name: packName, 105 | // TODO: DisplayName 106 | Policies: policies, 107 | } 108 | 109 | // Make an evaluator that can actually apply the rules using the above compiler. 110 | e := &evaler{c: compiler} 111 | 112 | return pack, e, nil 113 | } 114 | 115 | // policyPack holds the metadata for a complete Pulumi policy package. 116 | type policyPack struct { 117 | Name string `json:"name"` 118 | DisplayName string `json:"displayName"` 119 | Policies []*policyRule `json:"policies"` 120 | } 121 | 122 | // policyRule holds the metadata for a Pulumi policy rule, in addition to the OPA rule authored in *.rego. 123 | type policyRule struct { 124 | Name string `json:"name"` 125 | DisplayName string `json:"displayName"` 126 | Description string `json:"description"` 127 | Message string `json:"message"` 128 | Level enforcementLevel `json:"enforcementLevel"` 129 | } 130 | 131 | type enforcementLevel int 132 | 133 | const ( 134 | advisoryRule enforcementLevel = 0 135 | mandatoryRule enforcementLevel = 1 136 | ) 137 | -------------------------------------------------------------------------------- /cmd/pulumi-analyzer-policy-opa/serve.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, Pulumi Corporation. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | 20 | "github.com/pkg/errors" 21 | "google.golang.org/grpc" 22 | 23 | "github.com/pulumi/pulumi/pkg/resource/provider" 24 | "github.com/pulumi/pulumi/pkg/util/cmdutil" 25 | "github.com/pulumi/pulumi/pkg/util/logging" 26 | "github.com/pulumi/pulumi/pkg/util/rpcutil" 27 | pulumirpc "github.com/pulumi/pulumi/sdk/proto/go" 28 | ) 29 | 30 | // Serve fires up a Pulumi analyzer provider listening to inbound gRPC traffic, 31 | // and translates calls from Pulumi into actions against the OPA rules in packInfo. 32 | func Serve(pack *policyPack, e *evaler, args []string) error { 33 | // First inittialize all loggers. 34 | logging.InitLogging(false, 0, false) 35 | cmdutil.InitTracing(pack.Name, pack.Name, "") 36 | 37 | // Read the non-flags args and connect to the engine. 38 | if len(args) == 0 { 39 | return errors.New("fatal: could not connect to host RPC; missing argument") 40 | } 41 | host, err := provider.NewHostClient(args[0]) 42 | if err != nil { 43 | return errors.Wrapf(err, "fatal: could not connect to host RPC") 44 | } 45 | 46 | // Create a new gRPC server and listen for and serve incoming connections. 47 | port, done, err := rpcutil.Serve(0, nil, []func(*grpc.Server) error{ 48 | func(srv *grpc.Server) error { 49 | analyzer := NewAnalyzer(host, pack, e) 50 | pulumirpc.RegisterAnalyzerServer(srv, analyzer) 51 | return nil 52 | }, 53 | }, nil) 54 | if err != nil { 55 | return errors.Wrapf(err, "fatal: could not serve RPC") 56 | } 57 | 58 | // The plugin protocol requires that we now write out the port we've chosen to listen on. 59 | fmt.Printf("%d\n", port) 60 | 61 | // Finally, wait for the server to stop serving before returning. 62 | if err := <-done; err != nil { 63 | return errors.Wrapf(err, "fatal: plugin exit") 64 | } 65 | 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /examples/app/.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /node_modules/ 3 | yarn.lock 4 | -------------------------------------------------------------------------------- /examples/app/Pulumi.yaml: -------------------------------------------------------------------------------- 1 | name: simple-kubernetes 2 | runtime: nodejs 3 | description: A minimal Kubernetes TypeScript Pulumi program 4 | -------------------------------------------------------------------------------- /examples/app/README.md: -------------------------------------------------------------------------------- 1 | ## Build and test 2 | 3 | ```bash 4 | $ pulumi stack init dev 5 | Created stack 'dev' 6 | 7 | $ pulumi up --policy-pack ../policy-kubernetes 8 | Previewing update (dev): 9 | Type Name Plan Info 10 | + pulumi:pulumi:Stack simple-kubernetes-dev create 1 error 11 | + └─ kubernetes:apps:Deployment nginx create 12 | 13 | Diagnostics: 14 | pulumi:pulumi:Stack (simple-kubernetes-dev): 15 | error: preview failed 16 | 17 | Policy Violations: 18 | [mandatory] kubernetes v0.0.1 deny (nginx: kubernetes:apps/v1:Deployment) 19 | nginx-t6yfa9vr must include Kubernetes recommended labels: https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels 20 | ``` -------------------------------------------------------------------------------- /examples/app/index.ts: -------------------------------------------------------------------------------- 1 | import * as k8s from "@pulumi/kubernetes"; 2 | import * as kx from "@pulumi/kubernetesx"; 3 | 4 | const appLabels = { app: "nginx" }; 5 | const deployment = new k8s.apps.v1.Deployment("nginx", { 6 | spec: { 7 | selector: { matchLabels: appLabels }, 8 | replicas: 1, 9 | template: { 10 | metadata: { labels: appLabels }, 11 | spec: { containers: [{ name: "nginx", image: "nginx" }] } 12 | } 13 | } 14 | }); 15 | const pod = new k8s.core.v1.Pod("myapp", { 16 | spec: { 17 | containers: [{ 18 | name: "nginx-frontend", 19 | image: "nginx", 20 | }, { 21 | name: "mysql-backend", 22 | image: "mysql", 23 | }] 24 | } 25 | }); 26 | export const name = deployment.metadata.name; 27 | -------------------------------------------------------------------------------- /examples/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-kubernetes", 3 | "devDependencies": { 4 | "@types/node": "^10.0.0" 5 | }, 6 | "dependencies": { 7 | "@pulumi/pulumi": "^2.0.0", 8 | "@pulumi/kubernetes": "^2.0.0", 9 | "@pulumi/kubernetesx": "^0.1.1" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "outDir": "bin", 5 | "target": "es2016", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "experimentalDecorators": true, 10 | "pretty": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noImplicitReturns": true, 13 | "forceConsistentCasingInFileNames": true 14 | }, 15 | "files": [ 16 | "index.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /examples/policy-kubernetes/PulumiPolicy.yaml: -------------------------------------------------------------------------------- 1 | description: A minimal Policy Pack for Kubernetes using OPA. 2 | runtime: opa 3 | -------------------------------------------------------------------------------- /examples/policy-kubernetes/kubernetes.rego: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | name = input.metadata.name 4 | 5 | labels { 6 | input.metadata.labels["app.kubernetes.io/name"] 7 | input.metadata.labels["app.kubernetes.io/instance"] 8 | input.metadata.labels["app.kubernetes.io/version"] 9 | input.metadata.labels["app.kubernetes.io/component"] 10 | input.metadata.labels["app.kubernetes.io/part-of"] 11 | input.metadata.labels["app.kubernetes.io/managed-by"] 12 | } 13 | 14 | warn[msg] { 15 | input.kind == "Deployment" 16 | not labels 17 | msg = sprintf("%s must include Kubernetes recommended labels: https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels ", [name]) 18 | } 19 | 20 | deny[msg] { 21 | input.kind == "Pod" 22 | image := input.spec.containers[_].image 23 | not startswith(image, "hooli.com/") 24 | msg := sprintf("image '%v' comes from untrusted registry", [image]) 25 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pulumi/pulumi-policy-opa 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/golang/protobuf v1.5.3 7 | github.com/open-policy-agent/opa v0.43.1 8 | github.com/pkg/errors v0.9.1 9 | github.com/pulumi/pulumi v1.6.1 10 | google.golang.org/grpc v1.56.3 11 | ) 12 | -------------------------------------------------------------------------------- /scripts/get-version: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -o nounset -o errexit -o pipefail 3 | SCRIPT_DIR="$( cd "$( dirname "$0" )" && pwd )" 4 | COMMITISH=${1:-HEAD} 5 | DIRTY_TAG="" 6 | 7 | # Figure out if the worktree is dirty, we run update-index first 8 | # as we've seen cases in Travis where not doing so causes git to 9 | # treat the worktree as dirty when it is not. 10 | git update-index -q --refresh 11 | if ! git diff-files --quiet; then 12 | DIRTY_TAG="dirty" 13 | fi 14 | 15 | # If we have an exact tag, just use it. 16 | if git describe --tags --exact-match "${COMMITISH}" >/dev/null 2>&1; then 17 | echo -n "$(git describe --tags --exact-match "${COMMITISH}")" 18 | if [ ! -z "${DIRTY_TAG}" ]; then 19 | echo -n "+${DIRTY_TAG}" 20 | fi 21 | 22 | echo "" 23 | exit 0 24 | fi 25 | 26 | # Otherwise, increment the minor version version (if the package is 1.X or later) or the 27 | # patch version (if the package is pre 1.0), add the -alpha tag and some 28 | # commit metadata. If there's no existing tag, pretend a v0.0.0 was 29 | # there so we'll produce v0.0.1-dev builds. 30 | if git describe --tags --abbrev=0 "${COMMITISH}" > /dev/null 2>&1; then 31 | TAG=$(git describe --tags --abbrev=0 "${COMMITISH}") 32 | else 33 | TAG="v0.0.0" 34 | fi 35 | 36 | # Strip off any pre-release tag we might have (e.g. from doing a -rc build) 37 | TAG=${TAG%%-*} 38 | 39 | MAJOR=$(cut -d. -f1 <<< "${TAG}") 40 | MINOR=$(cut -d. -f2 <<< "${TAG}") 41 | PATCH=$(cut -d. -f3 <<< "${TAG}") 42 | 43 | if [ "${MAJOR}" = "v0" ]; then 44 | PATCH=$((${PATCH}+1)) 45 | else 46 | MINOR=$((${MINOR}+1)) 47 | PATCH=0 48 | fi 49 | 50 | # We want to include some additional information. To the base tag we 51 | # add a timestamp and commit hash. We use the timestamp of the commit 52 | # itself, not the date it was authored (so it will change when someone 53 | # rebases a PR into master, for example). 54 | echo -n "${MAJOR}.${MINOR}.${PATCH}-alpha.$(git show -s --format='%ct+g%h' ${COMMITISH})" 55 | if [ ! -z "${DIRTY_TAG}" ]; then 56 | echo -n ".${DIRTY_TAG}" 57 | fi 58 | 59 | echo "" 60 | --------------------------------------------------------------------------------