...
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.
In the root directory of your bundle create main.rego
Code Block | ||||
---|---|---|---|---|
| ||||
package main
import input.attributes.request.http as http_request
default allow = false
name := trim_prefix(replace(http_request.path, "-", ""), "/")
router[policy] = data.policies[name][policy].deny
deny[msg] {
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)
Create a directory policies/<app name> off the main directory.
In here create a policy called policy.rego
Code Block | ||||
---|---|---|---|---|
| ||||
package policies.rappopaprovider.policy
import input.attributes.request.http as http_request
import future.keywords.in
realm_name := "opa"
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})
}
deny[msg] {
not is_token_valid
msg = "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
}) |
This policy will verify the jwt token, check the token issue time and expiration time against the current time and also ensure the token contains the correct role.
If all these condtions are met the user will be granted access to the resource.
NGINX can be used for the bundles server: nginx.yaml
OPA server: opa.yaml
rapp-opa-provider: rapp-opa-provider.yaml
To test retrieve the access token from keycloak and run the curl command for the rapp-opa-provider url
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.