...
Code Block | ||||
---|---|---|---|---|
| ||||
package main import ( "context" "encoding/json" "fmt" "github.com/open-policy-agent/opa/rego" "io/ioutil" "net/http" "net/url" "os" ) type Jwttoken struct { Access_token string Expires_in int Refresh_expires_in int Refresh_token string Token_type string Not_before_policy int Session_state string Scope string } var token Jwttoken var opaPolicy string = ` package authz import future.keywords.in default allow = false jwks := jwks_request("http://keycloak:8080/auth/realms/opa/protocol/openid-connect/certs").body filtered_jwks := [ key | some key in jwks.keys key.use == "sig" ] token_cert := json.marshal({"keys": filtered_jwks}) token = { "isValid": isValid, "header": header, "payload": payload } { [isValid, header, payload] := io.jwt.decode_verify(input, { "cert": token_cert, "aud": "account", "iss": "http://keycloak:808 0/auth/realms/opa"}) } allow { is_token_valid } is_token_valid { token.isValid now := time.now_ns() / 1000000000 token.payload.iat <= now now < token.payload.exp token.payload.clientRole = "[opa-client-role]" } jwks_request(url) = http.send({ "url": url, "method": "GET", "force_cache": true, "force_json_decode": true, "force_cache_duration_seconds": 3600 # Cache response for an hour }) ` func getToken() string { clientSecret := "63wkv0RUXkp01pbqtNTSwghhTxeMW55I" clientId := "opacli" realmName := "opa" keycloakHost := "keycloak" keycloakPort := "8080" keycloakUrl := "http://" + keycloakHost + ":" + keycloakPort + "/auth/realms/" + realmName + "/protocol/openid-connect/token" resp, err := http.PostForm(keycloakUrl, url.Values{"client_secret": {clientSecret}, "grant_type": {"client_credentials"}, "client_id": {clientId}}) if err != nil { fmt.Println(err) panic("Something wrong with the credentials or url ") } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) json.Unmarshal([]byte(body), &token) return token.Access_token } func traceOpa(input string) { ctx := context.TODO() test := rego.New( rego.Query("x = data.authz.allow"), rego.Trace(true), rego.Module("example.rego", opaPolicy), rego.Input(input), ) test.Eval(ctx) rego.PrintTraceWithLocation(os.Stdout, test) } func evaluateOpa(input string) { ctx := context.TODO() query, err := rego.New( rego.Query("x = data.authz.allow"), rego.Module("example.rego", opaPolicy), ).PrepareForEval(ctx) if err != nil { // Handle error. fmt.Println(err.Error()) } results, err := query.Eval(ctx, rego.EvalInput(input)) // Inspect results. if err != nil { // Handle evaluation error. fmt.Println("Error: " + err.Error()) } else if len(results) == 0 { // Handle undefined result. fmt.Println("Results are empty") } else { // Handle result/decision. fmt.Printf("Results = %+v\n", results) //=> [{Expressions:[true] Bindings:map[x:true]}] } } func main() { tokenStr := getToken() traceOpa(tokenStr) evaluateOpa(tokenStr) } |
OPA bundles and dynamic composition
Method 1
We can combined OPA bundles with dynamic composition to provide different policies for differect services.
...
Code Block | ||||
---|---|---|---|---|
| ||||
package main import input.attributes.request.http as http_request default allow = false policy_realms := { "rappopaprovider": "opa" } name := trim_prefix(replace(http_request.path, "-", ""), "/") realm := policy_realms[name] router[policy] = data.policies[name][policy].deny deny[realmmsg] { policy := router[_] msg := policy[_] } allow { count(deny) == 0 } |
This main policy will use the request path to determine which policy to apply. (We remove the forward slash and and "-" characters)
...
Code Block | ||||
---|---|---|---|---|
| ||||
package policies.rappopaprovider.policy import input.attributes.request.http as http_request import future.keywords.in default certsrealm_name := "opa" realm_url := sprintf("" default realmhttp://keycloak:8080/auth/realms/%v", [realm_name]) certs_url := sprintf("%v/protocol/openid-connect/certs", [realm_url]) jwks := jwks_request(certs_url).body filtered_jwks := [ key | some key in jwks.keys key.use == "sig" ] token_cert := json.marshal({"keys": filtered_jwks}) token = { "isValid": isValid, "header": header, "payload": payload } { [_, encoded] := split(http_request.headers.authorization, " ") [isValid, header, payload] := io.jwt.decode_verify(encoded, { "cert": token_cert, "aud": "account", "iss": realm_url}) } deny[realmmsg] { not is_token_valid realm_urlmsg = sprintf("http://keycloak:8080/auth/realms/%s", realm) certs_url = sprintf("%s/protocol/openid-connect/certs", realm_url)"denied by rappopaprovider.policy: not a valid token" } is_token_valid { token.isValid now := time.now_ns() / 1000000000 token.payload.iat <= now now < token.payload.exp token.payload.clientRole = "[opa-client-role]" } jwks_request(url) = http.send({ "url": url, "method": "GET", "force_cache": true, "force_json_decode": true, "force_cache_duration_seconds": 3600 # Cache response for an hour }) |
...
Code Block | ||||
---|---|---|---|---|
| ||||
#!/bin/bash INGRESS_HOST=$(minikube ip) INGRESS_PORT=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.spec.ports[?(@.name=="http2")].nodePort}') TESTS=0 PASSED=0 FAILED=0 TEST_TS=$(date +%F-%T) TOKEN="" ACCESS_TOKEN="" REFRESH_TOKEN="" function get_token { local prefix="${1}" url="http://${KEYCLOAK_HOST}:${KEYCLOAK_PORT}/auth/realms" TOKEN=$(curl -s -X POST $url/opa/protocol/openid-connect/token -H \ "Content-Type: application/x-www-form-urlencoded" -d client_secret=63wkv0RUXkp01pbqtNTSwghhTxeMW55I \ -d 'grant_type=client_credentials' -d client_id=opacli) ACCESS_TOKEN=$(echo $TOKEN | jq -r '.access_token') } function run_test { local prefix="${1}" type=${2} msg="${3}" data=${4} TESTS=$((TESTS+1)) echo "Test ${TESTS}: Testing $type /${prefix}" get_token $prefix url=$INGRESS_HOST:$INGRESS_PORT"/"$prefix result=$(curl -s -X ${type} -H "Content-type: application/json" -H "Authorization: Bearer $ACCESS_TOKEN" $url) echo $result if [ "$result" != "$msg" ]; then echo "FAIL" FAILED=$((FAILED+1)) else echo "PASS" PASSED=$((PASSED+1)) fi echo "" } run_test "rapp-opa-provider" "GET" "Hello OPA World!" "" echo echo "-----------------------------------------------------------------------" echo "Number of Tests: $TESTS, Tests Passed: $PASSED, Tests Failed: $FAILED" echo "Date: $TEST_TS" echo "-----------------------------------------------------------------------" |
Method 2
We can also organize the policies in the following way.
Create a new file in your bundle to do the common processing:
Code Block | ||||
---|---|---|---|---|
| ||||
package policy.common.request
import input.attributes.request.http as http_request
import future.keywords.in
policy_realms := {
"rappopaprovider": "opa"
}
method = http_request.method
path = input.parsed_path
policy = trim_prefix(replace(http_request.path, "-", ""), "/")
realm_name := policy_realms[policy]
realm_url := sprintf("http://keycloak:8080/auth/realms/%v", [realm_name])
certs_url := sprintf("%v/protocol/openid-connect/certs", [realm_url])
jwks := jwks_request(certs_url).body
filtered_jwks := [ key |
some key in jwks.keys
key.use == "sig"
]
token_cert := json.marshal({"keys": filtered_jwks})
token = { "isValid": isValid, "header": header, "payload": payload } {
[_, encoded] := split(http_request.headers.authorization, " ")
[isValid, header, payload] := io.jwt.decode_verify(encoded, { "cert": token_cert, "aud": "account", "iss": realm_url})
}
jwks_request(url) = http.send({
"url": url,
"method": "GET",
"force_cache": true,
"force_json_decode": true,
"force_cache_duration_seconds": 3600 # Cache response for an hour
})
user = token.payload.sub
clientRole = token.payload.clientRole
audience = token.payload.aud
exp = token.payload.exp
iat = token.payload.iat |
Create another rules.rego file for you application e.g. policy/services/rappopaprovider/ingress/rules.rego
Code Block | ||||
---|---|---|---|---|
| ||||
package policy.services.rappopaprovider.ingress
import data.policy.common.request
allow = true {
request.token.isValid
request.method == "GET"
request.path = [ "rapp-opa-provider" ]
now := time.now_ns() / 1000000000
request.iat <= now
now < request.exp
request.clientRole = "[opa-client-role]"
} |
Lastly create the parent rules file that will call the appropiates policy based on the http request path
Code Block | ||||
---|---|---|---|---|
| ||||
package policy.ingress
import data.policy.common.request
import data.policy.services
allow = true {
services[request.policy].ingress.allow
} |
To use this set of rules make sure opa is pointing to the parent rules file : "–set=plugins.envoy_ext_authz_grpc.query=data.policy.ingress.allow"
Note If you do not wish to validate the jet you can use this code instead:
token = { "isValid": isValid, "payload": payload } {
authorization_header := input.attributes.request.http.headers.authorization
encoded_token := trim_prefix(authorization_header, "Bearer ")
payload := io.jwt.decode(encoded_token)[1]
isValid := true
}
OPA with prometheus and grafana
Add the following job to your prometheus.yaml file in the scrape_configs section:
- job_name: opa
scrape_interval: 10s
metrics_path: /metrics
static_configs:
- targets:
- opa.default:8181
This will enable metric collection from the opa /metrics endpoint:
The full ist is available here: Open Policy Agent Monitoring
Download the OPA metrics dashboard from grafana and import it into your grafance instance Open Policy Agent Metrics Dashboard
In the instance dropdown, type opa.default:8181 (assuming opa is running in the default namespace and metrics are being served on port 8181)
You should see a dashboard similar to the following:
OPA Profiling and Bench Marking
Below is a sample file we can use for profiling/benchmarking
Code Block | ||||
---|---|---|---|---|
| ||||
package rbactest
import data.policy.common.request
import data.policy.services
input = {
"attributes": {
"destination": {
"address": {
"socketAddress": {
"address": "172.17.0.15",
"portValue": 9000
}
},
"principal": "spiffe://cluster.local/ns/istio-nonrtric/sa/default"
},
"metadataContext": {},
"request": {
"http": {
"headers": {
":authority": "192.168.49.2:31000",
":method": "GET",
":path": "/rapp-opa-provider",
":scheme": "http",
"accept": "*/*",
"authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJDamJ4a2FONjRVcUdYNThWU2R3WjBxQTdWRmN1TGdEQWhnUWJTVG55UE9JIn0.eyJleHAiOjE2NTY0MDYwMTYsImlhdCI6MTY1NjQwNTcxNiwianRpIjoiOWE0MzAxODMtZjYzZS00MzY1LTg4NTMtYjc0ZTY2MmFhYTUwIiwiaXNzIjoiaHR0cDovL2tleWNsb2FrOjgwODAvYXV0aC9yZWFsbXMvb3BhIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjU3NGMxODQzLTZiMWEtNGMwZC04M2U0LTljMDkwYjM5ZjIyZiIsInR5cCI6IkJlYXJlciIsImF6cCI6Im9wYWNsaSIsImFjciI6IjEiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsImRlZmF1bHQtcm9sZXMtb3BhIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsib3BhY2xpIjp7InJvbGVzIjpbIm9wYS1jbGllbnQtcm9sZSJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJwcm9maWxlIGVtYWlsIiwiY2xpZW50SWQiOiJvcGFjbGkiLCJjbGllbnRIb3N0IjoiMTI3LjAuMC42IiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJjbGllbnRSb2xlIjoiW29wYS1jbGllbnQtcm9sZV0iLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZXJ2aWNlLWFjY291bnQtb3BhY2xpIiwiY2xpZW50QWRkcmVzcyI6IjEyNy4wLjAuNiJ9.k_NtngXgWyTPB2z8IArnxqvx3iYP18xc-1fWQoZ0Az2BOK3kfWrdSmIngn_ilwLXrTSdX-n_Tx_o2NLlwQ13RMRXN5zoJaCnU2iSXvel7hjYA_PSDZbv3MW1boqOZmqzuTA5ugRXuVAGQ42k0PIPNWqSm6JujUGfVzB_mrc43I3jXNuHuqwx6miat4NmJo2MCxM_Y1s39ixxREfQIovqFz1ky69IKfz8QcxyFhSsCmydjk4T6HufC3_SJO0XaBKWAoJpdcgdom1kYcIeoGxWWn3lX5E0kQ3eL4TH0F6IfCtjLZwFlzlhtDJItD6ddglJpEsc6rcuLw-06_VyjeqzSg",
"content-type": "application/json",
"user-agent": "curl/7.68.0",
},
"host": "192.168.49.2:31000",
"id": "15472195549358141958",
"method": "GET",
"path": "/rapp-opa-provider",
"protocol": "HTTP/1.1",
"scheme": "http"
},
"time": "2022-06-28T07:07:47.099076Z"
},
"source": {
"address": {
"socketAddress": {
"address": "172.17.0.4",
"portValue": 54862
}
},
"principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account"
}
},
"parsed_body": null,
"parsed_path": [
"rapp-opa-provider"
],
"parsed_query": {},
"truncated_body": false,
"version": {
"encoding": "protojson",
"ext_authz": "v3"
}
}
default allow = false
allow = true {
services[request.policy].ingress.allow
} |
opa eval --data rbactest.rego --profile --count=100 --format=pretty 'data.rbactest.allow'
false
+--------------------------------+---------+---------+--------------+-------------------+------------------------+
| METRIC | MIN | MAX | MEAN | 90% | 99% |
+--------------------------------+---------+---------+--------------+-------------------+------------------------+
| timer_rego_external_resolve_ns | 300 | 2400 | 461 | 600 | 2382.9999999999914 |
| timer_rego_load_files_ns | 3069300 | 6160300 | 4.024052e+06 | 4.82397e+06 | 6.152662999999996e+06 |
| timer_rego_module_compile_ns | 690400 | 2288700 | 983743 | 1.42366e+06 | 2.2863819999999986e+06 |
| timer_rego_module_parse_ns | 439300 | 1834400 | 613517 | 882270.0000000001 | 1.832610999999999e+06 |
| timer_rego_query_compile_ns | 49500 | 190100 | 68390 | 93410 | 189761.99999999983 |
| timer_rego_query_eval_ns | 25600 | 423300 | 40197 | 42630.00000000001 | 421415.99999999907 |
| timer_rego_query_parse_ns | 44700 | 674500 | 70035 | 81690 | 671625.9999999986 |
+--------------------------------+---------+---------+--------------+-------------------+------------------------+
+--------+--------+----------+---------+-----------+----------+----------+---------------------+
| MIN | MAX | MEAN | 90% | 99% | NUM EVAL | NUM REDO | LOCATION |
+--------+--------+----------+---------+-----------+----------+----------+---------------------+
| 14.1µs | 404µs | 25.251µs | 28.82µs | 402.438µs | 1 | 1 | data.rbactest.allow |
| 9.8µs | 27.8µs | 12.843µs | 16.48µs | 27.756µs | 1 | 1 | rbactest.rego:62 |
+--------+--------+----------+---------+-----------+----------+----------+---------------------+
opa bench --data rbactest.rego 'data.rbactest.allow'
+-------------------------------------------------+------------+
| samples | 22605 |
| ns/op | 47760 |
| B/op | 6269 |
| allocs/op | 112 |
| histogram_timer_rego_external_resolve_ns_75% | 400 |
| histogram_timer_rego_external_resolve_ns_90% | 500 |
| histogram_timer_rego_external_resolve_ns_95% | 500 |
| histogram_timer_rego_external_resolve_ns_99% | 871 |
| histogram_timer_rego_external_resolve_ns_99.9% | 29394 |
| histogram_timer_rego_external_resolve_ns_99.99% | 29800 |
| histogram_timer_rego_external_resolve_ns_count | 22605 |
| histogram_timer_rego_external_resolve_ns_max | 29800 |
| histogram_timer_rego_external_resolve_ns_mean | 434 |
| histogram_timer_rego_external_resolve_ns_median | 400 |
| histogram_timer_rego_external_resolve_ns_min | 200 |
| histogram_timer_rego_external_resolve_ns_stddev | 1045 |
| histogram_timer_rego_query_eval_ns_75% | 31100 |
| histogram_timer_rego_query_eval_ns_90% | 37210 |
| histogram_timer_rego_query_eval_ns_95% | 47160 |
| histogram_timer_rego_query_eval_ns_99% | 91606 |
| histogram_timer_rego_query_eval_ns_99.9% | 630561 |
| histogram_timer_rego_query_eval_ns_99.99% | 631300 |
| histogram_timer_rego_query_eval_ns_count | 22605 |
| histogram_timer_rego_query_eval_ns_max | 631300 |
| histogram_timer_rego_query_eval_ns_mean | 29182 |
| histogram_timer_rego_query_eval_ns_median | 25300 |
| histogram_timer_rego_query_eval_ns_min | 15200 |
| histogram_timer_rego_query_eval_ns_stddev | 32411 |
+-------------------------------------------------+------------+
OPA Sidecar injection
First create a namespace for your apps and enable istio and opa
kubectl create ns opa
kubectl label namespace opa opa-istio-injection="enabled"
kubectl label namespace opa istio-injection="enabled"
Create the opa injection obects using:
kubectl create -f opa_inject.yaml
Ensure your istio mesh config has been setup to include grcp local authorizer
kubectl edit configmap istio -n istio-system
Code Block | ||||
---|---|---|---|---|
| ||||
extensionProviders:
- envoyExtAuthzGrpc:
port: "9191"
service: local-opa-grpc.local
name: opa-local |
Update your rapp-provider authorization policy to use this provider:
Code Block | ||||
---|---|---|---|---|
| ||||
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: rapp-opa-provider-opa
namespace: opa
spec:
selector:
matchLabels:
app: rapp-opa-provider
action: CUSTOM
provider:
name: "opa-local"
rules:
- to:
- operation:
paths: ["/rapp-opa-provider"]
notPaths: ["/health"] |
Run the opa_test.sh script above and you should see a message confirming your connection to the service.
Note: References to keycloak need to be updated to include the keycloak schema i.e keycloak.default
Basic Authentication
We can add basic authentication to our NGINX bubdle server by following these steps:
Create a password file using the following command: sudo htpasswd -c .htpasswd <user>, you will be prompted to input the password.
This will produce a file called .htpasswd containing the username and encrypted password
e.g. admin:$apr1$tPQCjrVW$sokcSj4QVkncEDna0Fc2o/
Add the following configmap definitions to your nginx.yaml
Code Block | ||||
---|---|---|---|---|
| ||||
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-pwd-config
namespace: default
data:
.htpasswd: |
admin:$apr1$tPQCjrVW$sokcSj4QVkncEDna0Fc2o/
---
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-conf-config
namespace: default
data:
default.conf: |
server {
server_name localhost;
location ~ ^/bundles/(.*)$ {
root /usr/share/nginx/html/bundles;
try_files /$1 =404;
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/conf.d/conf/.htpasswd;
}
}
--- |
Then update your volumes and volume mounts to include these files with your deployment
Code Block | ||||
---|---|---|---|---|
| ||||
volumeMounts:
- name: bundlesdir
mountPath: /usr/share/nginx/html/bundles
readOnly: true
- name: nginx-conf
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
- name: nginx-pwd
mountPath: /etc/nginx/conf.d/conf/.htpasswd
subPath: .htpasswd
volumes:
- name: bundlesdir
hostPath:
# Ensure the file directory is created.
path: /var/opa/bundles
type: DirectoryOrCreate
- name: nginx-conf
configMap:
name: nginx-conf-config
defaultMode: 0644
- name: nginx-pwd
configMap:
name: nginx-pwd-config
defaultMode: 0644 |
This will add basic authentication to your bundles directory.
Run echo -n <username>:<password> | base64 to encrpt your usename and password
e.g. echo -n admin:admin | base64
YWRtaW46YWRtaW4=
Update the opa-istio-config ConfigMap in the opa_inject.yaml file to include the encrypted string as a token in the cedentials section:
Code Block | ||||
---|---|---|---|---|
| ||||
apiVersion: v1
kind: ConfigMap
metadata:
name: opa-istio-config
namespace: opa
data:
config.yaml: |
plugins:
envoy_ext_authz_grpc:
addr: :9191
path: policy/ingress/allow
decision_logs:
console: true
services:
- name: bundle-server
url: http://bundle-server.default
credentials:
bearer:
token: YWRtaW46YWRtaW4=
scheme: Basic
bundles:
authz:
service: bundle-server
resource: bundles/opa-bundle.tar.gz
persist: true
polling:
min_delay_seconds: 10
max_delay_seconds: 20
--- |
Your bundle is now protected with basic authentication.