mirror of
https://github.com/pomerium/pomerium.git
synced 2025-07-21 02:28:06 +02:00
Merge pull request #615 from pomerium/cdoxsey/policy-path
implement path-based route matching
This commit is contained in:
commit
45c706666c
17 changed files with 549 additions and 32 deletions
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
|
@ -33,8 +33,12 @@ jobs:
|
||||||
go-version: ${{ matrix.go-version }}
|
go-version: ${{ matrix.go-version }}
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
- name: Build dependencies
|
||||||
|
run: make build-deps
|
||||||
- name: Test
|
- name: Test
|
||||||
run: make test
|
run: |
|
||||||
|
export PATH=$PATH:$(go env GOPATH)/bin
|
||||||
|
make test
|
||||||
|
|
||||||
cover:
|
cover:
|
||||||
strategy:
|
strategy:
|
||||||
|
|
6
Makefile
6
Makefile
|
@ -27,7 +27,9 @@ CTIMEVAR=-X $(PKG)/internal/version.GitCommit=$(GITCOMMIT) \
|
||||||
-X $(PKG)/internal/version.ProjectURL=$(PKG)
|
-X $(PKG)/internal/version.ProjectURL=$(PKG)
|
||||||
GO_LDFLAGS=-ldflags "-s -w $(CTIMEVAR)"
|
GO_LDFLAGS=-ldflags "-s -w $(CTIMEVAR)"
|
||||||
GOOSARCHES = linux/amd64 darwin/amd64 windows/amd64
|
GOOSARCHES = linux/amd64 darwin/amd64 windows/amd64
|
||||||
|
MISSPELL_VERSION = v0.3.4
|
||||||
GOLANGCI_VERSION = v1.21.0
|
GOLANGCI_VERSION = v1.21.0
|
||||||
|
OPA_VERSION = v0.19.1
|
||||||
|
|
||||||
.PHONY: all
|
.PHONY: all
|
||||||
all: clean build-deps test lint spellcheck build ## Runs a clean, build, fmt, lint, test, and vet.
|
all: clean build-deps test lint spellcheck build ## Runs a clean, build, fmt, lint, test, and vet.
|
||||||
|
@ -36,8 +38,9 @@ all: clean build-deps test lint spellcheck build ## Runs a clean, build, fmt, li
|
||||||
.PHONY: build-deps
|
.PHONY: build-deps
|
||||||
build-deps: ## Install build dependencies
|
build-deps: ## Install build dependencies
|
||||||
@echo "==> $@"
|
@echo "==> $@"
|
||||||
@cd /tmp; GO111MODULE=on go get -u github.com/client9/misspell/cmd/misspell
|
@cd /tmp; GO111MODULE=on go get github.com/client9/misspell/cmd/misspell@${MISSPELL_VERSION}
|
||||||
@cd /tmp; GO111MODULE=on go get github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCI_VERSION}
|
@cd /tmp; GO111MODULE=on go get github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCI_VERSION}
|
||||||
|
@cd /tmp; GO111MODULE=on go get github.com/open-policy-agent/opa@${OPA_VERSION}
|
||||||
|
|
||||||
.PHONY: docs
|
.PHONY: docs
|
||||||
docs: ## Start the vuepress docs development server
|
docs: ## Start the vuepress docs development server
|
||||||
|
@ -68,6 +71,7 @@ lint: ## Verifies `golint` passes.
|
||||||
test: ## Runs the go tests.
|
test: ## Runs the go tests.
|
||||||
@echo "==> $@"
|
@echo "==> $@"
|
||||||
@go test -tags "$(BUILDTAGS)" $(shell go list ./... | grep -v vendor)
|
@go test -tags "$(BUILDTAGS)" $(shell go list ./... | grep -v vendor)
|
||||||
|
@opa test ./authorize/evaluator/opa/policy
|
||||||
|
|
||||||
.PHONY: spellcheck
|
.PHONY: spellcheck
|
||||||
spellcheck: # Spellcheck docs
|
spellcheck: # Spellcheck docs
|
||||||
|
|
|
@ -91,9 +91,11 @@ func Test_Eval(t *testing.T) {
|
||||||
}
|
}
|
||||||
req := struct {
|
req := struct {
|
||||||
Host string `json:"host,omitempty"`
|
Host string `json:"host,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
User string `json:"user,omitempty"`
|
User string `json:"user,omitempty"`
|
||||||
}{
|
}{
|
||||||
Host: tt.route,
|
Host: tt.route,
|
||||||
|
URL: "https://" + tt.route,
|
||||||
User: rawJWT,
|
User: rawJWT,
|
||||||
}
|
}
|
||||||
got, err := pe.IsAuthorized(context.TODO(), req)
|
got, err := pe.IsAuthorized(context.TODO(), req)
|
||||||
|
|
|
@ -8,7 +8,7 @@ default allow = false
|
||||||
# allow by email
|
# allow by email
|
||||||
allow {
|
allow {
|
||||||
some route
|
some route
|
||||||
input.host = route_policies[route].source
|
allowed_route(input.url, route_policies[route])
|
||||||
token.payload.email = route_policies[route].allowed_users[_]
|
token.payload.email = route_policies[route].allowed_users[_]
|
||||||
token.valid
|
token.valid
|
||||||
count(deny)==0
|
count(deny)==0
|
||||||
|
@ -17,8 +17,9 @@ allow {
|
||||||
# allow group
|
# allow group
|
||||||
allow {
|
allow {
|
||||||
some route
|
some route
|
||||||
input.host = route_policies[route].source
|
allowed_route(input.url, route_policies[route])
|
||||||
token.payload.groups[_] = route_policies[route].allowed_groups[_]
|
some group
|
||||||
|
token.payload.groups[group] == route_policies[route].allowed_groups[_]
|
||||||
token.valid
|
token.valid
|
||||||
count(deny)==0
|
count(deny)==0
|
||||||
}
|
}
|
||||||
|
@ -26,7 +27,7 @@ allow {
|
||||||
# allow by impersonate email
|
# allow by impersonate email
|
||||||
allow {
|
allow {
|
||||||
some route
|
some route
|
||||||
input.host = route_policies[route].source
|
allowed_route(input.url, route_policies[route])
|
||||||
token.payload.impersonate_email = route_policies[route].allowed_users[_]
|
token.payload.impersonate_email = route_policies[route].allowed_users[_]
|
||||||
token.valid
|
token.valid
|
||||||
count(deny)==0
|
count(deny)==0
|
||||||
|
@ -35,8 +36,9 @@ allow {
|
||||||
# allow by impersonate group
|
# allow by impersonate group
|
||||||
allow {
|
allow {
|
||||||
some route
|
some route
|
||||||
input.host = route_policies[route].source
|
allowed_route(input.url, route_policies[route])
|
||||||
token.payload.impersonate_groups[_] = route_policies[route].allowed_groups[_]
|
some group
|
||||||
|
token.payload.impersonate_groups[group] == route_policies[route].allowed_groups[_]
|
||||||
token.valid
|
token.valid
|
||||||
count(deny)==0
|
count(deny)==0
|
||||||
}
|
}
|
||||||
|
@ -44,7 +46,7 @@ allow {
|
||||||
# allow by domain
|
# allow by domain
|
||||||
allow {
|
allow {
|
||||||
some route
|
some route
|
||||||
input.host = route_policies[route].source
|
allowed_route(input.url, route_policies[route])
|
||||||
some domain
|
some domain
|
||||||
email_in_domain(token.payload.email, route_policies[route].allowed_domains[domain])
|
email_in_domain(token.payload.email, route_policies[route].allowed_domains[domain])
|
||||||
token.valid
|
token.valid
|
||||||
|
@ -54,13 +56,68 @@ allow {
|
||||||
# allow by impersonate domain
|
# allow by impersonate domain
|
||||||
allow {
|
allow {
|
||||||
some route
|
some route
|
||||||
input.host = route_policies[route].source
|
allowed_route(input.url, route_policies[route])
|
||||||
some domain
|
some domain
|
||||||
email_in_domain(token.payload.impersonate_email, route_policies[route].allowed_domains[domain])
|
email_in_domain(token.payload.impersonate_email, route_policies[route].allowed_domains[domain])
|
||||||
token.valid
|
token.valid
|
||||||
count(deny)==0
|
count(deny)==0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowed_route(input_url, policy){
|
||||||
|
input_url_obj := parse_url(input_url)
|
||||||
|
allowed_route_source(input_url_obj, policy)
|
||||||
|
allowed_route_prefix(input_url_obj, policy)
|
||||||
|
allowed_route_path(input_url_obj, policy)
|
||||||
|
allowed_route_regex(input_url_obj, policy)
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed_route_source(input_url_obj, policy) {
|
||||||
|
object.get(policy, "source", "") == ""
|
||||||
|
}
|
||||||
|
allowed_route_source(input_url_obj, policy) {
|
||||||
|
object.get(policy, "source", "") != ""
|
||||||
|
source_url_obj := parse_url(policy.source)
|
||||||
|
input_url_obj.host == source_url_obj.host
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed_route_prefix(input_url_obj, policy) {
|
||||||
|
object.get(policy, "prefix", "") == ""
|
||||||
|
}
|
||||||
|
allowed_route_prefix(input_url_obj, policy) {
|
||||||
|
object.get(policy, "prefix", "") != ""
|
||||||
|
startswith(input_url_obj.path, policy.prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed_route_path(input_url_obj, policy) {
|
||||||
|
object.get(policy, "path", "") == ""
|
||||||
|
}
|
||||||
|
allowed_route_path(input_url_obj, policy) {
|
||||||
|
object.get(policy, "path", "") != ""
|
||||||
|
policy.path == input_url_obj.path
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed_route_regex(input_url_obj, policy) {
|
||||||
|
object.get(policy, "regex", "") == ""
|
||||||
|
}
|
||||||
|
allowed_route_regex(input_url_obj, policy) {
|
||||||
|
object.get(policy, "regex", "") != ""
|
||||||
|
re_match(policy.regex, input_url_obj.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_url(str) = { "scheme": scheme, "host": host, "path": path } {
|
||||||
|
[_, scheme, host, rawpath] = regex.find_all_string_submatch_n(
|
||||||
|
`(?:(http[s]?)://)?([^/]+)([^?#]*)`,
|
||||||
|
str, 1)[0]
|
||||||
|
path = normalize_url_path(rawpath)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_url_path(str) = "/" {
|
||||||
|
str == ""
|
||||||
|
}
|
||||||
|
normalize_url_path(str) = str {
|
||||||
|
str != ""
|
||||||
|
}
|
||||||
|
|
||||||
email_in_domain(email, domain) {
|
email_in_domain(email, domain) {
|
||||||
x := split(email, "@")
|
x := split(email, "@")
|
||||||
count(x) == 2
|
count(x) == 2
|
||||||
|
|
102
authorize/evaluator/opa/policy/authz_test.rego
Normal file
102
authorize/evaluator/opa/policy/authz_test.rego
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
package pomerium.authz
|
||||||
|
|
||||||
|
jwt_header := {
|
||||||
|
"typ": "JWT",
|
||||||
|
"alg": "HS256"
|
||||||
|
}
|
||||||
|
signing_key := {
|
||||||
|
"kty": "oct",
|
||||||
|
"k": "OkFmqMK9U0dmPhMCW0VYy6D_raJKwEJsMdxqdnukThzko3D_XrsihwYE0pxrUSpm0JTrW2QpIz4rT1vdEvZw67WP4xrqjiwyd7PgpPTD5xvQBM7TIKiSW0X2R0pfq_OItszPQRtb7VirrSbGJiLNS-NJMMrYVKWWtUbVSTXEjL7VcFqML5PiSe7XDmyCZjpgEpfE5Q82zIeXM2sLrz6HW2A9IwGk7mWS0c57R_2JGyFO2tCA4zEIYhWvLE62Os2tZ6YrrwdB8n35jlPpgUE6poEvIU20lPLaocozXYMqAku-KJnloJlAzKg2Xa_0iSiSgSAumx44B3n7DQjg3jPhRg"
|
||||||
|
}
|
||||||
|
shared_key := base64url.decode(signing_key.k)
|
||||||
|
|
||||||
|
test_email_allowed {
|
||||||
|
user := io.jwt.encode_sign(jwt_header, {
|
||||||
|
"aud": ["example.com"],
|
||||||
|
"email": "joe@example.com"
|
||||||
|
}, signing_key)
|
||||||
|
|
||||||
|
allow with data.route_policies as [{
|
||||||
|
"source": "example.com",
|
||||||
|
"allowed_users": ["joe@example.com"]
|
||||||
|
}] with data.signing_key as signing_key with data.shared_key as shared_key with input as {
|
||||||
|
"url": "http://example.com",
|
||||||
|
"host": "example.com",
|
||||||
|
"user": user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test_example {
|
||||||
|
user := io.jwt.encode_sign(jwt_header, {
|
||||||
|
"aud": ["example.com"],
|
||||||
|
"email": "joe@example.com"
|
||||||
|
}, signing_key)
|
||||||
|
not allow with data.route_policies as [
|
||||||
|
{
|
||||||
|
"source": "http://example.com",
|
||||||
|
"path": "/a",
|
||||||
|
"allowed_domains": ["example.com"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "http://example.com",
|
||||||
|
"path": "/b",
|
||||||
|
"allowed_users": ["noone@pomerium.com"]
|
||||||
|
},
|
||||||
|
] with data.signing_key as signing_key with data.shared_key as shared_key with input as {
|
||||||
|
"url": "http://example.com/b",
|
||||||
|
"host": "example.com",
|
||||||
|
"user": user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test_email_denied {
|
||||||
|
user := io.jwt.encode_sign(jwt_header, {
|
||||||
|
"aud": ["example.com"],
|
||||||
|
"email": "joe@example.com"
|
||||||
|
}, signing_key)
|
||||||
|
|
||||||
|
not allow with data.route_policies as [{
|
||||||
|
"source": "example.com",
|
||||||
|
"allowed_users": ["bob@example.com"]
|
||||||
|
}] with data.signing_key as signing_key with data.shared_key as shared_key with input as {
|
||||||
|
"url": "http://example.com",
|
||||||
|
"host": "example.com",
|
||||||
|
"user": user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test_parse_url {
|
||||||
|
url := parse_url("http://example.com/some/path?qs")
|
||||||
|
url.scheme == "http"
|
||||||
|
url.host == "example.com"
|
||||||
|
url.path == "/some/path"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_allowed_route_source {
|
||||||
|
allowed_route("http://example.com", {"source": "example.com"})
|
||||||
|
allowed_route("http://example.com", {"source": "http://example.com"})
|
||||||
|
allowed_route("http://example.com", {"source": "https://example.com"})
|
||||||
|
allowed_route("http://example.com/", {"source": "https://example.com"})
|
||||||
|
allowed_route("http://example.com", {"source": "https://example.com/"})
|
||||||
|
allowed_route("http://example.com/", {"source": "https://example.com/"})
|
||||||
|
not allowed_route("http://example.org", {"source": "example.com"})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_allowed_route_prefix {
|
||||||
|
allowed_route("http://example.com", {"prefix": "/"})
|
||||||
|
allowed_route("http://example.com/admin/somepath", {"prefix": "/admin"})
|
||||||
|
not allowed_route("http://example.com", {"prefix": "/admin"})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_allowed_route_path {
|
||||||
|
allowed_route("http://example.com", {"path": "/"})
|
||||||
|
allowed_route("http://example.com/", {"path": "/"})
|
||||||
|
not allowed_route("http://example.com/admin/somepath", {"path": "/admin"})
|
||||||
|
not allowed_route("http://example.com", {"path": "/admin"})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_allowed_route_regex {
|
||||||
|
allowed_route("http://example.com", {"regex": ".*"})
|
||||||
|
allowed_route("http://example.com/admin/somepath", {"regex": "/admin/.*"})
|
||||||
|
not allowed_route("http://example.com", {"regex": "[xyz]"})
|
||||||
|
}
|
File diff suppressed because one or more lines are too long
|
@ -4,6 +4,7 @@ package authorize
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/pomerium/pomerium/authorize/evaluator"
|
"github.com/pomerium/pomerium/authorize/evaluator"
|
||||||
"github.com/pomerium/pomerium/internal/grpc/authorize"
|
"github.com/pomerium/pomerium/internal/grpc/authorize"
|
||||||
|
@ -22,7 +23,7 @@ func (a *Authorize) IsAuthorized(ctx context.Context, in *authorize.IsAuthorized
|
||||||
Method: in.GetRequestMethod(),
|
Method: in.GetRequestMethod(),
|
||||||
RequestURI: in.GetRequestRequestUri(),
|
RequestURI: in.GetRequestRequestUri(),
|
||||||
RemoteAddr: in.GetRequestRemoteAddr(),
|
RemoteAddr: in.GetRequestRemoteAddr(),
|
||||||
URL: in.GetRequestUrl(),
|
URL: getFullURL(in.GetRequestUrl(), in.GetRequestHost()),
|
||||||
}
|
}
|
||||||
return a.pe.IsAuthorized(ctx, req)
|
return a.pe.IsAuthorized(ctx, req)
|
||||||
}
|
}
|
||||||
|
@ -38,3 +39,17 @@ func cloneHeaders(in protoHeader) map[string][]string {
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getFullURL(rawurl, host string) string {
|
||||||
|
u, err := url.Parse(rawurl)
|
||||||
|
if err != nil {
|
||||||
|
u = &url.URL{Path: rawurl}
|
||||||
|
}
|
||||||
|
if u.Host == "" {
|
||||||
|
u.Host = host
|
||||||
|
}
|
||||||
|
if u.Scheme == "" {
|
||||||
|
u.Scheme = "http"
|
||||||
|
}
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
|
@ -47,3 +47,19 @@ func TestAuthorize_IsAuthorized(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_getFullURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
rawurl, host, expect string
|
||||||
|
}{
|
||||||
|
{"https://www.example.com/admin", "", "https://www.example.com/admin"},
|
||||||
|
{"https://www.example.com/admin", "example.com", "https://www.example.com/admin"},
|
||||||
|
{"/admin", "example.com", "http://example.com/admin"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
actual := getFullURL(tt.rawurl, tt.host)
|
||||||
|
if actual != tt.expect {
|
||||||
|
t.Errorf("expected getFullURL(%s, %s) to be %s, but got %s", tt.rawurl, tt.host, tt.expect, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -152,7 +152,7 @@ func Test_parsePolicyFile(t *testing.T) {
|
||||||
want []Policy
|
want []Policy
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{"simple json", []byte(fmt.Sprintf(`{"policy":[{"from": "%s","to":"%s"}]}`, source, dest)), []Policy{{From: source, To: dest, Source: &HostnameURL{sourceURL}, Destination: destURL}}, false},
|
{"simple json", []byte(fmt.Sprintf(`{"policy":[{"from": "%s","to":"%s"}]}`, source, dest)), []Policy{{From: source, To: dest, Source: &StringURL{sourceURL}, Destination: destURL}}, false},
|
||||||
{"bad from", []byte(`{"policy":[{"from": "%","to":"httpbin.org"}]}`), nil, true},
|
{"bad from", []byte(`{"policy":[{"from": "%","to":"httpbin.org"}]}`), nil, true},
|
||||||
{"bad to", []byte(`{"policy":[{"from": "pomerium.io","to":"%"}]}`), nil, true},
|
{"bad to", []byte(`{"policy":[{"from": "pomerium.io","to":"%"}]}`), nil, true},
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,9 +21,14 @@ type Policy struct {
|
||||||
AllowedGroups []string `mapstructure:"allowed_groups" yaml:"allowed_groups,omitempty" json:"allowed_groups,omitempty"`
|
AllowedGroups []string `mapstructure:"allowed_groups" yaml:"allowed_groups,omitempty" json:"allowed_groups,omitempty"`
|
||||||
AllowedDomains []string `mapstructure:"allowed_domains" yaml:"allowed_domains,omitempty" json:"allowed_domains,omitempty"`
|
AllowedDomains []string `mapstructure:"allowed_domains" yaml:"allowed_domains,omitempty" json:"allowed_domains,omitempty"`
|
||||||
|
|
||||||
Source *HostnameURL `yaml:",omitempty" json:"source,omitempty"`
|
Source *StringURL `yaml:",omitempty" json:"source,omitempty"`
|
||||||
Destination *url.URL `yaml:",omitempty" json:"destination,omitempty"`
|
Destination *url.URL `yaml:",omitempty" json:"destination,omitempty"`
|
||||||
|
|
||||||
|
// Additional route matching options
|
||||||
|
Prefix string `mapstructure:"prefix" yaml:"prefix,omitempty" json:"prefix,omitempty"`
|
||||||
|
Path string `mapstructure:"path" yaml:"path,omitempty" json:"path,omitempty"`
|
||||||
|
Regex string `mapstructure:"regex" yaml:"regex,omitempty" json:"regex,omitempty"`
|
||||||
|
|
||||||
// Allow unauthenticated HTTP OPTIONS requests as per the CORS spec
|
// Allow unauthenticated HTTP OPTIONS requests as per the CORS spec
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Preflighted_requests
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Preflighted_requests
|
||||||
CORSAllowPreflight bool `mapstructure:"cors_allow_preflight" yaml:"cors_allow_preflight,omitempty"`
|
CORSAllowPreflight bool `mapstructure:"cors_allow_preflight" yaml:"cors_allow_preflight,omitempty"`
|
||||||
|
@ -85,7 +90,14 @@ func (p *Policy) Validate() error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("config: policy bad source url %w", err)
|
return fmt.Errorf("config: policy bad source url %w", err)
|
||||||
}
|
}
|
||||||
p.Source = &HostnameURL{source}
|
|
||||||
|
// Make sure there's no path set on the from url
|
||||||
|
if !(source.Path == "" || source.Path == "/") {
|
||||||
|
return fmt.Errorf("config: policy source url (%s) contains a path, but it should be set using the path field instead",
|
||||||
|
source.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Source = &StringURL{source}
|
||||||
|
|
||||||
p.Destination, err = urlutil.ParseAndValidateURL(p.To)
|
p.Destination, err = urlutil.ParseAndValidateURL(p.To)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -135,13 +147,12 @@ func (p *Policy) String() string {
|
||||||
return fmt.Sprintf("%s → %s", p.Source.String(), p.Destination.String())
|
return fmt.Sprintf("%s → %s", p.Source.String(), p.Destination.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// HostnameURL wraps url but marshals only the host representation of that
|
// StringURL stores a URL as a string in json.
|
||||||
// url struct.
|
type StringURL struct {
|
||||||
type HostnameURL struct {
|
|
||||||
*url.URL
|
*url.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON returns the URLs host as json.
|
// MarshalJSON returns the URLs host as json.
|
||||||
func (j *HostnameURL) MarshalJSON() ([]byte, error) {
|
func (u *StringURL) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(j.Host)
|
return json.Marshal(u.String())
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ func Test_PolicyValidate(t *testing.T) {
|
||||||
{"empty from host", Policy{From: "https://", To: "https://httpbin.corp.example"}, true},
|
{"empty from host", Policy{From: "https://", To: "https://httpbin.corp.example"}, true},
|
||||||
{"empty from scheme", Policy{From: "httpbin.corp.example", To: "https://httpbin.corp.example"}, true},
|
{"empty from scheme", Policy{From: "httpbin.corp.example", To: "https://httpbin.corp.example"}, true},
|
||||||
{"empty to scheme", Policy{From: "https://httpbin.corp.example", To: "//httpbin.corp.example"}, true},
|
{"empty to scheme", Policy{From: "https://httpbin.corp.example", To: "//httpbin.corp.example"}, true},
|
||||||
|
{"path in from", Policy{From: "https://httpbin.corp.example/some/path", To: "https://httpbin.corp.example"}, true},
|
||||||
{"cors policy", Policy{From: "https://httpbin.corp.example", To: "https://httpbin.corp.notatld", CORSAllowPreflight: true}, false},
|
{"cors policy", Policy{From: "https://httpbin.corp.example", To: "https://httpbin.corp.notatld", CORSAllowPreflight: true}, false},
|
||||||
{"public policy", Policy{From: "https://httpbin.corp.example", To: "https://httpbin.corp.notatld", AllowPublicUnauthenticatedAccess: true}, false},
|
{"public policy", Policy{From: "https://httpbin.corp.example", To: "https://httpbin.corp.notatld", AllowPublicUnauthenticatedAccess: true}, false},
|
||||||
{"public and whitelist", Policy{From: "https://httpbin.corp.example", To: "https://httpbin.corp.notatld", AllowPublicUnauthenticatedAccess: true, AllowedUsers: []string{"test@domain.example"}}, true},
|
{"public and whitelist", Policy{From: "https://httpbin.corp.example", To: "https://httpbin.corp.notatld", AllowPublicUnauthenticatedAccess: true, AllowedUsers: []string{"test@domain.example"}}, true},
|
||||||
|
@ -57,8 +58,8 @@ func TestPolicy_String(t *testing.T) {
|
||||||
want string
|
want string
|
||||||
wantFrom string
|
wantFrom string
|
||||||
}{
|
}{
|
||||||
{"good", "https://pomerium.io", "https://localhost", "https://pomerium.io → https://localhost", `"pomerium.io"`},
|
{"good", "https://pomerium.io", "https://localhost", "https://pomerium.io → https://localhost", `"https://pomerium.io"`},
|
||||||
{"failed to validate", "https://pomerium.io", "localhost", "https://pomerium.io → localhost", `"pomerium.io"`},
|
{"failed to validate", "https://pomerium.io", "localhost", "https://pomerium.io → localhost", `"https://pomerium.io"`},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
@ -753,12 +753,27 @@ Policy contains route specific settings, and access control details. If you are
|
||||||
|
|
||||||
<<< @/docs/configuration/examples/config/policy.example.yaml
|
<<< @/docs/configuration/examples/config/policy.example.yaml
|
||||||
|
|
||||||
|
Policy routes are checked in the order they appear in the policy, so more specific routes should appear before less specific routes. For example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
policies:
|
||||||
|
- from: http://from.example.com
|
||||||
|
to: http://to.example.com
|
||||||
|
prefix: /admin
|
||||||
|
allowed_groups: ["superuser"]
|
||||||
|
- from: http://from.example.com
|
||||||
|
to: http://to.example.com
|
||||||
|
allow_public_unauthenticated_access: true
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example an incoming request with a path prefix of `/admin` would be handled by the first route (which is restricted to superusers). All other requests for `from.example.com` would be handled by the second route (which is open to the public).
|
||||||
|
|
||||||
A list of policy configuration variables follows.
|
A list of policy configuration variables follows.
|
||||||
|
|
||||||
### From
|
### From
|
||||||
|
|
||||||
- `yaml`/`json` setting: `from`
|
- `yaml`/`json` setting: `from`
|
||||||
- Type: `URL` (must contain a scheme and hostname)
|
- Type: `URL` (must contain a scheme and hostname, must not contain a path)
|
||||||
- Required
|
- Required
|
||||||
- Example: `https://httpbin.corp.example.com`
|
- Example: `https://httpbin.corp.example.com`
|
||||||
|
|
||||||
|
@ -773,6 +788,33 @@ A list of policy configuration variables follows.
|
||||||
|
|
||||||
`To` is the destination of a proxied request. It can be an internal resource, or an external resource.
|
`To` is the destination of a proxied request. It can be an internal resource, or an external resource.
|
||||||
|
|
||||||
|
### Prefix
|
||||||
|
|
||||||
|
- `yaml`/`json` setting: `prefix`
|
||||||
|
- Type: `string`
|
||||||
|
- Optional
|
||||||
|
- Example: `/admin`
|
||||||
|
|
||||||
|
If set, the route will only match incoming requests with a path that begins with the specified prefix.
|
||||||
|
|
||||||
|
### Path
|
||||||
|
|
||||||
|
- `yaml`/`json` setting: `path`
|
||||||
|
- Type: `string`
|
||||||
|
- Optional
|
||||||
|
- Example: `/admin/some/exact/path`
|
||||||
|
|
||||||
|
If set, the route will only match incoming requests with a path that is an exact match for the specified path.
|
||||||
|
|
||||||
|
### Regex
|
||||||
|
|
||||||
|
- `yaml`/`json` setting: `regex`
|
||||||
|
- Type: `string` (containing a regular expression)
|
||||||
|
- Optional
|
||||||
|
- Example: `^/(admin|superuser)/.*$`
|
||||||
|
|
||||||
|
If set, the route will only match incoming requests with a path that matches the specified regular expression. The supported syntax is the same as the Go [regexp package](https://golang.org/pkg/regexp/) which is based on [re2](https://github.com/google/re2/wiki/Syntax).
|
||||||
|
|
||||||
### Allowed Users
|
### Allowed Users
|
||||||
|
|
||||||
- `yaml`/`json` setting: `allowed_users`
|
- `yaml`/`json` setting: `allowed_users`
|
||||||
|
|
|
@ -5,6 +5,45 @@ description: >-
|
||||||
for Pomerium. Please read it carefully.
|
for Pomerium. Please read it carefully.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# Since 0.8.0
|
||||||
|
|
||||||
|
## Breaking
|
||||||
|
|
||||||
|
### Using paths in from URLs
|
||||||
|
|
||||||
|
Although it's unlikely anyone ever used it, prior to 0.8.0 the policy configuration allowed you to specify a `from` field with a path component:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
policy:
|
||||||
|
- from: "https://example.com/some/path"
|
||||||
|
```
|
||||||
|
The proxy and authorization server would simply ignore the path and route/authorize based on the host name.
|
||||||
|
|
||||||
|
With the introduction of `prefix`, `path` and `regex` fields to the policy route configuration, we decided not to support using a path in the `from` url, since the behavior was somewhat ambiguous and better handled by the explicit fields.
|
||||||
|
|
||||||
|
To avoid future confusion, the application will now declare any configuration which contains a `from` field with a path as invalid, with this error message:
|
||||||
|
|
||||||
|
```
|
||||||
|
config: policy source url (%s) contains a path, but it should be set using the path field instead
|
||||||
|
```
|
||||||
|
|
||||||
|
If you see this error you can fix it by simply removing the path from the `from` field and moving it to a `prefix` field.
|
||||||
|
|
||||||
|
In other words, this configuration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
policy:
|
||||||
|
- from: "http://example.com/some/path"
|
||||||
|
```
|
||||||
|
|
||||||
|
Should be written like this:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
policy:
|
||||||
|
- from: "http://example.com"
|
||||||
|
prefix: "/some/path"
|
||||||
|
```
|
||||||
|
|
||||||
# Since 0.6.0
|
# Since 0.6.0
|
||||||
|
|
||||||
## Breaking
|
## Breaking
|
||||||
|
|
|
@ -115,6 +115,7 @@ func (p *Proxy) Verify(verifyOnly bool) http.Handler {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httputil.NewError(http.StatusBadRequest, err)
|
return httputil.NewError(http.StatusBadRequest, err)
|
||||||
}
|
}
|
||||||
|
originalRequest := p.getOriginalRequest(r, uri)
|
||||||
|
|
||||||
if _, err := sessions.FromContext(r.Context()); err != nil {
|
if _, err := sessions.FromContext(r.Context()); err != nil {
|
||||||
if verifyOnly {
|
if verifyOnly {
|
||||||
|
@ -130,10 +131,7 @@ func (p *Proxy) Verify(verifyOnly bool) http.Handler {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Host = uri.Host
|
if err := p.authorize(w, originalRequest); err != nil {
|
||||||
r.URL = uri
|
|
||||||
r.RequestURI = uri.String()
|
|
||||||
if err := p.authorize(w, r); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -143,3 +141,10 @@ func (p *Proxy) Verify(verifyOnly bool) http.Handler {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) getOriginalRequest(r *http.Request, originalURL *url.URL) *http.Request {
|
||||||
|
originalRequest := r.Clone(r.Context())
|
||||||
|
originalRequest.Host = originalURL.Host
|
||||||
|
originalRequest.URL = originalURL
|
||||||
|
return originalRequest
|
||||||
|
}
|
||||||
|
|
|
@ -14,6 +14,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
stdhttputil "net/http/httputil"
|
stdhttputil "net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
@ -230,8 +232,8 @@ func (p *Proxy) reverseProxyHandler(r *mux.Router, policy config.Policy) *mux.Ro
|
||||||
|
|
||||||
// 4. Override any custom transport settings (e.g. TLS settings, etc)
|
// 4. Override any custom transport settings (e.g. TLS settings, etc)
|
||||||
proxy.Transport = p.roundTripperFromPolicy(&policy)
|
proxy.Transport = p.roundTripperFromPolicy(&policy)
|
||||||
// 5. Create a sub-router for a given route's hostname (`httpbin.corp.example.com`)
|
// 5. Create a sub-router with a matcher derived from the policy (host, path, etc...)
|
||||||
rp := r.Host(policy.Source.Host).Subrouter()
|
rp := r.MatcherFunc(routeMatcherFuncFromPolicy(policy)).Subrouter()
|
||||||
rp.PathPrefix("/").Handler(proxy)
|
rp.PathPrefix("/").Handler(proxy)
|
||||||
|
|
||||||
// Optional: If websockets are enabled, do not set a handler request timeout
|
// Optional: If websockets are enabled, do not set a handler request timeout
|
||||||
|
@ -323,3 +325,42 @@ func (p *Proxy) roundTripperFromPolicy(policy *config.Policy) http.RoundTripper
|
||||||
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
p.Handler.ServeHTTP(w, r)
|
p.Handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// routeMatcherFuncFromPolicy returns a mux matcher function which compares an http request with a policy.
|
||||||
|
//
|
||||||
|
// Routes can be filtered by the `source`, `prefix`, `path` and `regex` fields in the policy config.
|
||||||
|
func routeMatcherFuncFromPolicy(policy config.Policy) mux.MatcherFunc {
|
||||||
|
// match by source
|
||||||
|
sourceMatches := func(r *http.Request) bool {
|
||||||
|
return r.Host == policy.Source.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
// match by prefix
|
||||||
|
prefixMatches := func(r *http.Request) bool {
|
||||||
|
return policy.Prefix == "" ||
|
||||||
|
strings.HasPrefix(r.URL.Path, policy.Prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// match by path
|
||||||
|
pathMatches := func(r *http.Request) bool {
|
||||||
|
return policy.Path == "" ||
|
||||||
|
r.URL.Path == policy.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
// match by path regex
|
||||||
|
var regexMatches func(*http.Request) bool
|
||||||
|
if policy.Regex == "" {
|
||||||
|
regexMatches = func(r *http.Request) bool { return true }
|
||||||
|
} else if re, err := regexp.Compile(policy.Regex); err == nil {
|
||||||
|
regexMatches = func(r *http.Request) bool {
|
||||||
|
return re.MatchString(r.URL.Path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Error().Err(err).Str("regex", policy.Regex).Msg("proxy: invalid regex in policy, ignoring route")
|
||||||
|
regexMatches = func(r *http.Request) bool { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(r *http.Request, rm *mux.RouteMatch) bool {
|
||||||
|
return sourceMatches(r) && prefixMatches(r) && pathMatches(r) && regexMatches(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -276,3 +277,138 @@ func TestNewReverseProxy(t *testing.T) {
|
||||||
t.Errorf("got body %q; expected %q", g, e)
|
t.Errorf("got body %q; expected %q", g, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRouteMatcherFuncFromPolicy(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
source, prefix, path, regex string
|
||||||
|
incomingURL string
|
||||||
|
expect bool
|
||||||
|
msg string
|
||||||
|
}{
|
||||||
|
// host in source
|
||||||
|
{"https://www.example.com", "", "", "",
|
||||||
|
"https://www.example.com", true,
|
||||||
|
"should match when host is the same as source host"},
|
||||||
|
{"https://www.example.com/", "", "", "",
|
||||||
|
"https://www.example.com", true,
|
||||||
|
"should match when host is the same as source host with trailing slash"},
|
||||||
|
{"https://www.example.com", "", "", "",
|
||||||
|
"https://www.google.com", false,
|
||||||
|
"should not match when host is different from source host"},
|
||||||
|
|
||||||
|
// path prefix
|
||||||
|
{"https://www.example.com", "/admin", "", "",
|
||||||
|
"https://www.example.com/admin/someaction", true,
|
||||||
|
"should match when path begins with prefix"},
|
||||||
|
{"https://www.example.com", "/admin", "", "",
|
||||||
|
"https://www.example.com/notadmin", false,
|
||||||
|
"should not match when path does not begin with prefix"},
|
||||||
|
|
||||||
|
// path
|
||||||
|
{"https://www.example.com", "", "/admin", "",
|
||||||
|
"https://www.example.com/admin", true,
|
||||||
|
"should match when path is the same as the policy path"},
|
||||||
|
{"https://www.example.com", "", "/admin", "",
|
||||||
|
"https://www.example.com/admin/someaction", false,
|
||||||
|
"should not match when path merely begins with the policy path"},
|
||||||
|
{"https://www.example.com", "", "/admin", "",
|
||||||
|
"https://www.example.com/notadmin", false,
|
||||||
|
"should not match when path is different from the policy path"},
|
||||||
|
|
||||||
|
// path regex
|
||||||
|
{"https://www.example.com", "", "", "^/admin/[a-z]+$",
|
||||||
|
"https://www.example.com/admin/someaction", true,
|
||||||
|
"should match when path matches policy path regex"},
|
||||||
|
{"https://www.example.com", "", "", "^/admin/[a-z]+$",
|
||||||
|
"https://www.example.com/notadmin", false,
|
||||||
|
"should not match when path does not match policy path regex"},
|
||||||
|
{"https://www.example.com", "", "", "invalid[",
|
||||||
|
"https://www.example.com/invalid", false,
|
||||||
|
"should not match on invalid policy path regex"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
srcURL, err := url.Parse(tt.source)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
src := &config.StringURL{URL: srcURL}
|
||||||
|
matcher := routeMatcherFuncFromPolicy(config.Policy{
|
||||||
|
Source: src,
|
||||||
|
Prefix: tt.prefix,
|
||||||
|
Path: tt.path,
|
||||||
|
Regex: tt.regex,
|
||||||
|
})
|
||||||
|
req, err := http.NewRequest("GET", tt.incomingURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
actual := matcher(req, nil)
|
||||||
|
if actual != tt.expect {
|
||||||
|
t.Errorf("%s (source=%s prefix=%s path=%s regex=%s incoming-url=%s)",
|
||||||
|
tt.msg, tt.source, tt.prefix, tt.path, tt.regex, tt.incomingURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicyPrefixRouting(t *testing.T) {
|
||||||
|
adminServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
io.WriteString(w, "admin: "+r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer adminServer.Close()
|
||||||
|
|
||||||
|
publicServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
io.WriteString(w, "public: "+r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer publicServer.Close()
|
||||||
|
|
||||||
|
opts := testOptions(t)
|
||||||
|
opts.Policies = []config.Policy{
|
||||||
|
{
|
||||||
|
From: "https://from.example.com",
|
||||||
|
To: "http://" + adminServer.Listener.Addr().String(),
|
||||||
|
Prefix: "/admin",
|
||||||
|
AllowPublicUnauthenticatedAccess: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
From: "https://from.example.com",
|
||||||
|
To: "http://" + publicServer.Listener.Addr().String(),
|
||||||
|
AllowPublicUnauthenticatedAccess: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := New(opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error creating proxy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("admin", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET", "https://from.example.com/admin/path", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error creating http request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
p.ServeHTTP(rr, req)
|
||||||
|
rr.Flush()
|
||||||
|
|
||||||
|
if rr.Body.String() != "admin: /admin/path" {
|
||||||
|
t.Errorf("expected admin request to go to the admin backend")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-admin", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET", "https://from.example.com/nonadmin/path", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error creating http request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
p.ServeHTTP(rr, req)
|
||||||
|
rr.Flush()
|
||||||
|
|
||||||
|
if rr.Body.String() != "public: /nonadmin/path" {
|
||||||
|
t.Errorf("expected non-admin request to go to the public backend")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
42
scripts/build-dev-docker.bash
Executable file
42
scripts/build-dev-docker.bash
Executable file
|
@ -0,0 +1,42 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
_dir=/tmp/pomerium-dev-docker
|
||||||
|
mkdir -p "$_dir"
|
||||||
|
|
||||||
|
# build linux binary
|
||||||
|
env GOOS=linux \
|
||||||
|
GOARCH=amd64 \
|
||||||
|
CGO_ENABLED=0 \
|
||||||
|
GO111MODULE=on \
|
||||||
|
go build \
|
||||||
|
-ldflags "-s -w" \
|
||||||
|
-o "$_dir/pomerium" \
|
||||||
|
./cmd/pomerium
|
||||||
|
|
||||||
|
# build docker image
|
||||||
|
(
|
||||||
|
|
||||||
|
|
||||||
|
cd $_dir
|
||||||
|
cat <<EOF >config.yaml
|
||||||
|
|
||||||
|
EOF
|
||||||
|
cat <<EOF >Dockerfile
|
||||||
|
FROM gcr.io/distroless/base:debug
|
||||||
|
WORKDIR /pomerium
|
||||||
|
COPY pomerium /bin/pomerium
|
||||||
|
COPY config.yaml /pomerium/config.yaml
|
||||||
|
ENTRYPOINT [ "/bin/pomerium" ]
|
||||||
|
CMD ["-config","/pomerium/config.yaml"]
|
||||||
|
EOF
|
||||||
|
docker build --tag=pomerium/pomerium:dev .
|
||||||
|
|
||||||
|
# build for minikube
|
||||||
|
if command -v minikube >/dev/null 2>&1 ; then
|
||||||
|
eval "$(minikube docker-env --shell=bash)"
|
||||||
|
docker build --tag=pomerium/pomerium:dev .
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
)
|
Loading…
Add table
Add a link
Reference in a new issue