diff --git a/Dockerfile b/Dockerfile index 5f2c3a2..4729b68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,10 @@ USER candig WORKDIR /app/ +RUN curl -L -o opa https://openpolicyagent.org/downloads/v0.63.0/opa_linux_amd64_static + +RUN chmod 755 ./opa + RUN touch /app/initial_setup ENTRYPOINT ["bash", "/app/entrypoint.sh"] \ No newline at end of file diff --git a/defaults/roles.json b/defaults/site_roles.json similarity index 75% rename from defaults/roles.json rename to defaults/site_roles.json index ed9ad78..6c67c18 100644 --- a/defaults/roles.json +++ b/defaults/site_roles.json @@ -1,9 +1,9 @@ { - "roles": { - "site_admin": [ + "site_roles": { + "admin": [ "SITE_ADMIN_USER" ], - "site_curator": [ + "curator": [ ], "local_team": [ "USER1" diff --git a/entrypoint.sh b/entrypoint.sh index cf72a88..9b4e94c 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -3,32 +3,28 @@ set -Euo pipefail OPA_ROOT_TOKEN=$(cat /run/secrets/opa-root-token) +OPA_SERVICE_TOKEN=$(cat /run/secrets/opa-service-token) SITE_ADMIN_USER=$(cat /run/secrets/site_admin_name) USER1=$(cat /run/secrets/user1_name) USER2=$(cat /run/secrets/user2_name) if [[ -f "/app/initial_setup" ]]; then # set up our default values - sed -i s/CLIENT_ID/$KEYCLOAK_CLIENT_ID/ /app/permissions_engine/idp.rego && sed -i s/CLIENT_ID/$KEYCLOAK_CLIENT_ID/ /app/permissions_engine/authz.rego - sed -i s/CANDIG_USER_KEY/$CANDIG_USER_KEY/ /app/permissions_engine/idp.rego && sed -i s/CANDIG_USER_KEY/$CANDIG_USER_KEY/ /app/permissions_engine/authz.rego + sed -i s/CANDIG_USER_KEY/$CANDIG_USER_KEY/ /app/permissions_engine/idp.rego # set up default users in default jsons: - sed -i s/SITE_ADMIN_USER/$SITE_ADMIN_USER/ /app/defaults/roles.json - sed -i s/USER1/$USER1/ /app/defaults/roles.json - sed -i s/USER2/$USER2/ /app/defaults/roles.json + sed -i s/SITE_ADMIN_USER/$SITE_ADMIN_USER/ /app/defaults/site_roles.json + sed -i s/USER1/$USER1/ /app/defaults/site_roles.json + sed -i s/USER2/$USER2/ /app/defaults/site_roles.json sed -i s/SITE_ADMIN_USER/$SITE_ADMIN_USER/ /app/defaults/programs.json sed -i s/USER1/$USER1/ /app/defaults/programs.json sed -i s/USER2/$USER2/ /app/defaults/programs.json - OPA_SERVICE_TOKEN=$(cat /run/secrets/opa-service-token) sed -i s/OPA_SERVICE_TOKEN/$OPA_SERVICE_TOKEN/ /app/permissions_engine/authz.rego sed -i s/OPA_ROOT_TOKEN/$OPA_ROOT_TOKEN/ /app/permissions_engine/authz.rego - # set up vault URL everywhere - sed -i s@VAULT_URL@$VAULT_URL@ /app/permissions_engine/authz.rego - sed -i s@VAULT_URL@$VAULT_URL@ /app/permissions_engine/service.rego - sed -i s@VAULT_URL@$VAULT_URL@ /app/permissions_engine/idp.rego - sed -i s@VAULT_URL@$VAULT_URL@ /app/permissions_engine/permissions.rego + # set up vault URL + sed -i s@VAULT_URL@$VAULT_URL@ /app/permissions_engine/vault.rego echo "initializing stores" python3 /app/initialize_vault_store.py diff --git a/healthcheck.py b/healthcheck.py index d5addc0..f3a691c 100644 --- a/healthcheck.py +++ b/healthcheck.py @@ -7,13 +7,7 @@ def perform_healthcheck(): try: - body = { - "input": { - "service": "opa", - "token": "token" # this isn't important; even if it's wrong, it returns 200 - } - } - response = requests.post(f"{opa_url}/v1/data/service/verified", json=body) + response = requests.get(f"{opa_url}/v1/data/service/service-info") response.raise_for_status() print("Health check passed!") return True diff --git a/initialize_vault_store.py b/initialize_vault_store.py index 22105cc..ccf29d2 100644 --- a/initialize_vault_store.py +++ b/initialize_vault_store.py @@ -3,7 +3,7 @@ from authx.auth import set_service_store_secret, add_provider_to_opa, add_program_to_opa import sys -## Initializes Vault's opa service store with the information for our IDP and the data in roles.json, paths.json, programs.json +## Initializes Vault's opa service store with the information for our IDP and the data in site_roles.json, paths.json, programs.json results = [] @@ -25,9 +25,9 @@ sys.exit(3) results.append(response) - with open('/app/defaults/roles.json') as f: + with open('/app/defaults/site_roles.json') as f: data = f.read() - response, status_code = set_service_store_secret("opa", key="roles", value=data) + response, status_code = set_service_store_secret("opa", key="site_roles", value=data) if status_code != 200: sys.exit(2) results.append(response) diff --git a/permissions_engine/authz.rego b/permissions_engine/authz.rego index da51976..e638064 100644 --- a/permissions_engine/authz.rego +++ b/permissions_engine/authz.rego @@ -13,6 +13,9 @@ rights = { "allowed": { "path": ["v1", "data", "permissions", "allowed"] }, + "site_admin": { + "path": ["v1", "data", "permissions", "site_admin"] + }, "tokenControlledAccessREMS": { "path": ["v1", "data", "ga4ghPassport", "tokenControlledAccessREMS"] } @@ -26,7 +29,7 @@ tokens = { "roles": ["admin"] }, service_token : { - "roles": ["datasets", "allowed", "tokenControlledAccessREMS"] + "roles": ["datasets", "allowed", "site_admin", "tokenControlledAccessREMS"] } } @@ -52,35 +55,14 @@ identity_rights[right] { # Right is in the identity_rights set if... right := rights[role] # Role has rights defined. } -import data.store_token.token as vault_token - -# If user is site_admin, allow always -import future.keywords.in - -roles = http.send({"method": "get", "url": "VAULT_URL/v1/opa/roles", "headers": {"X-Vault-Token": vault_token}}).body.data.roles -user_key := decode_verify_token_output[_][2].CANDIG_USER_KEY # get user key from the token payload - -allow { - user_key in roles.site_admin -} - -keys = http.send({"method": "get", "url": "VAULT_URL/v1/opa/data", "headers": {"X-Vault-Token": vault_token}}).body.data.keys -decode_verify_token_output[issuer] := output { - some i - issuer := keys[i].iss - cert := keys[i].cert - output := io.jwt.decode_verify( # Decode and verify in one-step - input.identity, - { # With the supplied constraints: - "cert": cert, - "iss": issuer, - "aud": "CLIENT_ID" - } - ) -} - # Any service should be able to verify that a service is who it says it is: allow { input.path == ["v1", "data", "service", "verified"] input.method == "POST" -} \ No newline at end of file +} + +# Service-info path for healthcheck +allow { + input.path == ["v1", "data", "service", "service-info"] + input.method == "GET" +} diff --git a/permissions_engine/idp.rego b/permissions_engine/idp.rego index 117a9ad..c82ecd2 100644 --- a/permissions_engine/idp.rego +++ b/permissions_engine/idp.rego @@ -5,19 +5,20 @@ package idp # Store decode and verified token # -import data.store_token.token as token -keys = http.send({"method": "get", "url": "VAULT_URL/v1/opa/data", "headers": {"X-Vault-Token": token}}).body.data.keys +import data.vault.keys as keys +import future.keywords.in decode_verify_token_output[issuer] := output { some i issuer := keys[i].iss cert := keys[i].cert + aud := keys[i].aud[_] output := io.jwt.decode_verify( # Decode and verify in one-step input.token, { # With the supplied constraints: "cert": cert, "iss": issuer, - "aud": "CLIENT_ID" + "aud": aud } ) } @@ -39,11 +40,8 @@ trusted_researcher = true { } # -# This user is a site admin if they have the site_admin role +# If the issuer in the token is the same as the first listed in keys, this is issued by the local issuer # -import future.keywords.in - -roles = http.send({"method": "get", "url": "VAULT_URL/v1/opa/roles", "headers": {"X-Vault-Token": token}}).body.data.roles -site_admin = true { - user_key in roles.site_admin +is_local_token = true { + keys[i].iss in object.keys(decode_verify_token_output) } diff --git a/permissions_engine/permissions.rego b/permissions_engine/permissions.rego index eceb755..4b5ee12 100644 --- a/permissions_engine/permissions.rego +++ b/permissions_engine/permissions.rego @@ -3,7 +3,6 @@ package permissions # This is the set of policy definitions for the permissions engine. # -import data.store_token.token as token # # Provided: # input = { @@ -15,7 +14,6 @@ import data.store_token.token as token # import data.idp.valid_token import data.idp.user_key -import data.idp.site_admin # # what programs are available to this user? @@ -23,11 +21,8 @@ import data.idp.site_admin import future.keywords.in -all_programs = http.send({"method": "get", "url": "VAULT_URL/v1/opa/programs", "headers": {"X-Vault-Token": token}}).body.data.programs -program_auths[p] := program { - some p in all_programs - program := http.send({"method": "get", "url": concat("/", ["VAULT_URL/v1/opa/programs", p]) , "headers": {"X-Vault-Token": token}}).body.data[p] -} +import data.vault.all_programs as all_programs +import data.vault.program_auths as program_auths readable_programs[p] { some p in all_programs @@ -39,7 +34,26 @@ curateable_programs[p] { user_key in program_auths[p].program_curators } -paths = http.send({"method": "get", "url": "VAULT_URL/v1/opa/paths", "headers": {"X-Vault-Token": token}}).body.data.paths +import data.vault.paths as paths + +# debugging +valid_token := valid_token +readable_get[p] := output { + some p in paths.read.get + output := regex.match(p, input.body.path) +} +readable_post[p] := output { + some p in paths.read.post + output := regex.match(p, input.body.path) +} +curateable_get[p] := output { + some p in paths.curate.get + output := regex.match(p, input.body.path) +} +curateable_post[p] := output { + some p in paths.curate.post + output := regex.match(p, input.body.path) +} # which datasets can this user see for this method, path default datasets = [] @@ -70,7 +84,7 @@ else := curateable_programs { valid_token input.body.method = "GET" - regex.match(paths.read.get[_], input.body.path) == true + regex.match(paths.curate.get[_], input.body.path) == true } else := curateable_programs @@ -88,4 +102,12 @@ allowed := true else := true { site_admin +} + +# +# This user is a site admin if they have the site_admin role +# +import data.vault.site_roles as site_roles +site_admin = true { + user_key in site_roles.admin } \ No newline at end of file diff --git a/permissions_engine/service.rego b/permissions_engine/service.rego index 0e190f1..62eb3b3 100644 --- a/permissions_engine/service.rego +++ b/permissions_engine/service.rego @@ -3,11 +3,10 @@ package service # Verifies that a service is who it says it is # -import data.store_token.token as token - -url = concat("/", ["VAULT_URL/v1", input.service, "token", input.token]) -service_token = http.send({"method": "get", "url": url, "headers": {"X-Vault-Token": token}}).body.data.token +import data.vault.service_token as service_token verified { service_token == input.token -} \ No newline at end of file +} + +service-info := "opa service is running" \ No newline at end of file diff --git a/permissions_engine/vault.rego b/permissions_engine/vault.rego new file mode 100644 index 0000000..fb39183 --- /dev/null +++ b/permissions_engine/vault.rego @@ -0,0 +1,26 @@ +package vault +# +# Obtain secrets from Opa's service secret store in Vault +# + +import future.keywords.in +import data.store_token.token as vault_token +import data.idp.user_key + +# keys are the IDP keys for verifying JWTs, used by idp.rego and authz.rego +keys = http.send({"method": "get", "url": "VAULT_URL/v1/opa/data", "headers": {"X-Vault-Token": vault_token}}).body.data.keys + +# paths are the paths authorized for methods, used by permissions.rego +paths = http.send({"method": "get", "url": "VAULT_URL/v1/opa/paths", "headers": {"X-Vault-Token": vault_token}}).body.data.paths + +# service_token gets the token saved for a service, used by service.rego +service_token = http.send({"method": "get", "url": concat("/", ["VAULT_URL/v1", input.service, "token", input.token]), "headers": {"X-Vault-Token": vault_token}}).body.data.token + +# site_roles are site-wide authorizations, used by permissions.rego and authz.rego +site_roles = http.send({"method": "get", "url": "VAULT_URL/v1/opa/site_roles", "headers": {"X-Vault-Token": vault_token}}).body.data.site_roles + +all_programs = http.send({"method": "get", "url": "VAULT_URL/v1/opa/programs", "headers": {"X-Vault-Token": vault_token}}).body.data.programs +program_auths[p] := program { + some p in all_programs + program := http.send({"method": "get", "url": concat("/", ["VAULT_URL/v1/opa/programs", p]) , "headers": {"X-Vault-Token": vault_token}}).body.data[p] +} diff --git a/requirements.txt b/requirements.txt index ec09205..cd3ae70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ requests jq -candigv2-authx@git+https://github.com/CanDIG/candigv2-authx.git@v2.2.1 +pytest==7.2.0 +candigv2-authx@git+https://github.com/CanDIG/candigv2-authx.git@daisieh/aud + diff --git a/tests/test_opa_permissions.py b/tests/test_opa_permissions.py new file mode 100644 index 0000000..083a0c5 --- /dev/null +++ b/tests/test_opa_permissions.py @@ -0,0 +1,221 @@ +import json +import os +import re +import sys +import pytest +import subprocess +import tempfile + + +# assumes that we are running pytest from the repo directory +REPO_DIR = os.path.abspath(f"{os.path.dirname(os.path.realpath(__file__))}/..") +DEFAULTS_DIR = f"{REPO_DIR}/defaults" +sys.path.insert(0, os.path.abspath(f"{REPO_DIR}")) + +@pytest.fixture +def site_roles(): + return { + "admin": [ + "site_admin@test.ca" + ], + "curator": [], + "local_team": [ + "user1@test.ca" + ], + "mohccn_network": [ + "user1@test.ca", + "user2@test.ca", + "other1@other.ca" + ] + } + + +@pytest.fixture +def programs(): + return { + "SYNTHETIC-1": { + "date_created": "2020-01-01", + "program_curators": [ + "user1@test.ca" + ], + "program_id": "SYNTHETIC-1", + "team_members": [ + "user1@test.ca" + ] + }, + "SYNTHETIC-2": { + "date_created": "2020-03-01", + "program_curators": [ + "user2@test.ca" + ], + "program_id": "SYNTHETIC-2", + "team_members": [ + "user2@test.ca" + ] + }, + "SYNTHETIC-3": { + "date_created": "2020-03-01", + "program_curators": [ + "user1@test.ca", + "user3@test.ca" + ], + "program_id": "SYNTHETIC-3", + "team_members": [ + "user1@test.ca", + "user2@test.ca", + "user3@test.ca" + ] + }, + "SYNTHETIC-4": { + "date_created": "2020-03-01", + "program_curators": [ + "user4@test.ca" + ], + "program_id": "SYNTHETIC-4", + "team_members": [ + "user1@test.ca", + "user4@test.ca" + ] + } + } + + +def setup_vault(user, site_roles, programs): + vault = {"vault": {}} + vault["vault"]["program_auths"] = programs + vault["vault"]["all_programs"] = list(programs.keys()) + vault["vault"]["site_roles"] = site_roles + with open(f"{DEFAULTS_DIR}/paths.json") as f: + paths = json.load(f) + vault["vault"]["paths"] = paths["paths"] + return vault + + +def evaluate_opa(user, input, key, expected_result, site_roles, programs): + args = [ + "./opa", "eval", + "--data", "permissions_engine/authz.rego", + "--data", "permissions_engine/permissions.rego", + ] + vault = setup_vault(user, site_roles, programs) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as vault_fp: + json.dump(vault, vault_fp) + args.extend(["--data", vault_fp.name]) + vault_fp.close() + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as idp_fp: + idp = {"idp": { + "user_key": user, + "valid_token": True + } + } + json.dump(idp, idp_fp) + idp_fp.close() + args.extend(["--data", idp_fp.name]) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as input_fp: + json.dump(input, input_fp) + input_fp.close() + args.extend(["--input", input_fp.name]) + + # finally, query arg: + args.append("data.permissions") + print(json.dumps(vault)) + print(json.dumps(idp)) + print(json.dumps({"input": input})) + p = subprocess.run(args, stdout=subprocess.PIPE) + r = json.loads(p.stdout) + print(r) + result =r['result'][0]['expressions'][0]['value'] + if key in result: + assert result[key] == expected_result + else: + assert expected_result == False + + +def get_site_admin_tests(): + return [ + ( # user1 is not a site admin + "user1@test.ca", + False + ), + ( # site_admin is a site admin + "site_admin@test.ca", + True + ) + ] + + +@pytest.mark.parametrize('user, expected_result', get_site_admin_tests()) +def test_site_admin(user, expected_result, site_roles, programs): + evaluate_opa(user, {}, "site_admin", expected_result, site_roles, programs) + + +def get_user_datasets(): + return [ + ( # site admin should be able to read all datasets + "site_admin@test.ca", + { + "body": { + "path": "/ga4gh/drs/v1/cohorts/", + "method": "GET" + } + }, + ["SYNTHETIC-1", "SYNTHETIC-2", "SYNTHETIC-3", "SYNTHETIC-4"] + ), + ( # user1 can view the datasets it's a member of + "user1@test.ca", + { + "body": { + "path": "/v2/discovery/programs/", + "method": "GET" + } + }, + ["SYNTHETIC-1", "SYNTHETIC-3", "SYNTHETIC-4"] + ) + ] + + +@pytest.mark.parametrize('user, input, expected_result', get_user_datasets()) +def test_user_datasets(user, input, expected_result, site_roles, programs): + evaluate_opa(user, input, "datasets", expected_result, site_roles, programs) + + +def get_curation_allowed(): + return [ + ( # site admin should be able to curate all datasets + "site_admin@test.ca", + { + "body": { + "path": "/ga4gh/drs/v1/cohorts/", + "method": "POST" + } + }, + True + ), + ( # user1 can curate the datasets it's a curator of + "user1@test.ca", + { + "body": { + "path": "/ga4gh/drs/v1/cohorts/", + "method": "POST", + "program": "SYNTHETIC-1" + } + }, + True + ), + ( # user1 cannot curate the datasets it's not a curator of + "user1@test.ca", + { + "body": { + "path": "/ga4gh/drs/v1/cohorts/", + "method": "POST", + "program": "SYNTHETIC-2" + } + }, + False + ) + ] + +@pytest.mark.parametrize('user, input, expected_result', get_curation_allowed()) +def test_curation_allowed(user, input, expected_result, site_roles, programs): + evaluate_opa(user, input, "allowed", expected_result, site_roles, programs)