diff --git a/apps/authz/src/app/opa/rego/lib/criterias/approvals.rego b/apps/authz/src/app/opa/rego/lib/criterias/approvals.rego new file mode 100644 index 000000000..39cf0420c --- /dev/null +++ b/apps/authz/src/app/opa/rego/lib/criterias/approvals.rego @@ -0,0 +1,137 @@ +package main + +import future.keywords.in + +match_signers(possible_signers, threshold) = result { + signature := input.signatures[_] + signature.signer == input.principal.uid + + matched_signers := {signer | + signature := input.signatures[_] + signer := signature.signer + signer in possible_signers + } + + missing_signers := {signer | + signer := possible_signers[_] + not signer in matched_signers + } + + result := { + "matched_signers": matched_signers, + "possible_signers": missing_signers, + "threshold_passed": count(matched_signers) >= threshold, + } +} + +check_approval(approval) = result { + approval.countPrincipal == true + approval.entityType == "Narval::User" + + possible_signers := {signer | signer := approval.entityIds[_]} | {input.principal.uid} + match := match_signers(possible_signers, approval.threshold) + + result := { + "approval": approval, + "match": match, + } +} + +check_approval(approval) = result { + approval.countPrincipal == false + approval.entityType == "Narval::User" + + possible_signers := {signer | + signer := approval.entityIds[_] + signer != input.principal.uid + } + + match := match_signers(possible_signers, approval.threshold) + + result := { + "approval": approval, + "match": match, + } +} + +check_approval(approval) = result { + approval.countPrincipal == true + approval.entityType == "Narval::UserGroup" + + possible_signers := {user | + group := approval.entityIds[_] + signers := data.entities.user_groups[group].users + user := signers[_] + } | {input.principal.uid} + + match := match_signers(possible_signers, approval.threshold) + + result := { + "approval": approval, + "match": match, + } +} + +check_approval(approval) = result { + approval.countPrincipal == false + approval.entityType == "Narval::UserGroup" + + possible_signers := {user | + group := approval.entityIds[_] + signers := data.entities.user_groups[group].users + user := signers[_] + user != input.principal.uid + } + + match := match_signers(possible_signers, approval.threshold) + + result := { + "approval": approval, + "match": match, + } +} + +check_approval(approval) = result { + approval.countPrincipal == true + approval.entityType == "Narval::UserRole" + + possible_signers := {user.uid | + user := data.entities.users[_] + user.role in approval.entityIds + } | {input.principal.uid} + + match := match_signers(possible_signers, approval.threshold) + + result := { + "approval": approval, + "match": match, + } +} + +check_approval(approval) = result { + approval.countPrincipal == false + approval.entityType == "Narval::UserRole" + + possible_signers := {user.uid | + user := data.entities.users[_] + user.role in approval.entityIds + user.uid != input.principal.uid + } + + match := match_signers(possible_signers, approval.threshold) + + result := { + "approval": approval, + "match": match, + } +} + +get_approvals_result(approvals) := result { + approvalsSatisfied := [approval | approval = approvals[_]; approval.match.threshold_passed == true] + approvalsMissing := [approval | approval = approvals[_]; approval.match.threshold_passed == false] + + result := { + "approvalsSatisfied": approvalsSatisfied, + "approvalsMissing": approvalsMissing, + } +} diff --git a/apps/authz/src/app/opa/rego/lib/criterias/destination.rego b/apps/authz/src/app/opa/rego/lib/criterias/destination.rego new file mode 100644 index 000000000..d2f811cd2 --- /dev/null +++ b/apps/authz/src/app/opa/rego/lib/criterias/destination.rego @@ -0,0 +1,23 @@ +package main + +import future.keywords.in + +check_destination_address(values) { + values == wildcard +} + +check_destination_address(values) { + destination.address in values +} + +check_destination_classification(values) { + values == wildcard +} + +check_destination_classification(values) { + not destination.classification +} + +check_destination_classification(values) { + destination.classification in values +} diff --git a/apps/authz/src/app/opa/rego/lib/criterias/principal.rego b/apps/authz/src/app/opa/rego/lib/criterias/principal.rego new file mode 100644 index 000000000..93a465dc8 --- /dev/null +++ b/apps/authz/src/app/opa/rego/lib/criterias/principal.rego @@ -0,0 +1,28 @@ +package main + +import future.keywords.in + +check_principal_id(values) { + values == wildcard +} + +check_principal_id(values) { + principal.uid in values +} + +check_principal_role(values) { + values == wildcard +} + +check_principal_role(values) { + principal.role in values +} + +check_principal_groups(values) { + values == wildcard +} + +check_principal_groups(values) { + group := principal_groups[_] + group in values +} \ No newline at end of file diff --git a/apps/authz/src/app/opa/rego/lib/criterias/resource.rego b/apps/authz/src/app/opa/rego/lib/criterias/resource.rego new file mode 100644 index 000000000..a77becf40 --- /dev/null +++ b/apps/authz/src/app/opa/rego/lib/criterias/resource.rego @@ -0,0 +1,41 @@ +package main + +import future.keywords.in + +check_wallet_id(values) { + values == wildcard +} + +check_wallet_id(values) { + resource.uid in values +} + +check_wallet_groups(values) { + values == wildcard +} + +check_wallet_groups(values) { + group := wallet_groups[_] + group in values +} + +check_wallet_chain_id(values) { + values == wildcard +} + +check_wallet_chain_id(values) { + not resource.chainId +} + +check_wallet_chain_id(values) { + resource.chainId in values +} + +check_wallet_assignees(values) { + values == wildcard +} + +check_wallet_assignees(values) { + assignee := resource.assignees[_] + assignee in values +} \ No newline at end of file diff --git a/apps/authz/src/app/opa/rego/lib/criterias/source.rego b/apps/authz/src/app/opa/rego/lib/criterias/source.rego new file mode 100644 index 000000000..b56b045e4 --- /dev/null +++ b/apps/authz/src/app/opa/rego/lib/criterias/source.rego @@ -0,0 +1,31 @@ +package main + +import future.keywords.in + +check_source_account_type(values) { + values == wildcard +} + +check_source_account_type(values) { + source.accountType in values +} + +check_source_address(values) { + values == wildcard +} + +check_source_address(values) { + source.address in values +} + +check_source_classification(values) { + values == wildcard +} + +check_source_classification(values) { + not source.classification +} + +check_source_classification(values) { + source.classification in values +} \ No newline at end of file diff --git a/apps/authz/src/app/opa/rego/lib/criterias/transfer_token.rego b/apps/authz/src/app/opa/rego/lib/criterias/transfer_token.rego new file mode 100644 index 000000000..5af5f757f --- /dev/null +++ b/apps/authz/src/app/opa/rego/lib/criterias/transfer_token.rego @@ -0,0 +1,65 @@ +package main + +import future.keywords.in + +check_transfer_token_type(values) { + values == wildcard +} + +check_transfer_token_type(values) { + input.intent.type in values +} + +check_transfer_token_address(values) { + values == wildcard +} + +check_transfer_token_address(values) { + input.intent.native in values +} + +check_transfer_token_address(values) { + input.intent.native.address in values +} + +check_transfer_token_address(values) { + input.intent.token in values +} + +check_transfer_token_address(values) { + input.intent.token.address in values +} + +check_transfer_token_operation(operation) { + operation == wildcard +} + +check_transfer_token_operation(operation) { + operation.operator == "eq" + operation.value == input.intent.amount +} + +check_transfer_token_operation(operation) { + operation.operator == "neq" + operation.value != input.intent.amount +} + +check_transfer_token_operation(operation) { + operation.operator == "gt" + operation.value < input.intent.amount +} + +check_transfer_token_operation(operation) { + operation.operator == "lt" + operation.value > input.intent.amount +} + +check_transfer_token_operation(operation) { + operation.operator == "gte" + operation.value <= input.intent.amount +} + +check_transfer_token_operation(operation) { + operation.operator == "lte" + operation.value >= input.intent.amount +} \ No newline at end of file diff --git a/apps/authz/src/app/opa/rego/lib/data.json b/apps/authz/src/app/opa/rego/lib/data.json new file mode 100644 index 000000000..23d384213 --- /dev/null +++ b/apps/authz/src/app/opa/rego/lib/data.json @@ -0,0 +1,65 @@ +{ + "entities":{ + "users": { + "test-bob-uid": { + "uid": "test-bob-uid", + "role": "root" + }, + "test-alice-uid": { + "uid": "test-alice-uid", + "role": "member" + }, + "test-foo-uid": { + "uid": "test-foo-uid", + "role": "admin" + }, + "0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43": { + "uid": "0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43", + "role": "admin" + } + }, + "wallets": {"eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e": { + "uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "address": "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "accountType": "eoa", + "assignees": ["test-bob-uid", "test-bar-uid"] + }}, + "user_groups": { + "test-user-group-one-uid": { + "uid": "test-user-group-one-uid", + "name": "dev", + "users": ["test-bob-uid", "test-bar-uid"] + }, + "test-user-group-two-uid": { + "uid": "test-user-group-two-uid", + "name": "finance", + "users": ["test-bob-uid", "test-bar-uid"] + } + }, + "wallet_groups": {"test-wallet-group-one-uid": { + "uid": "test-wallet-group-one-uid", + "name": "dev", + "wallets": ["eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"] + }}, + "address_book": { + "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3": { + "uid": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "address": "0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "chain_id": 137, + "classification": "internal" + }, + "eip155:137:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e": { + "uid": "eip155:137:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "address": "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "chain_id": 137, + "classification": "wallet" + }, + "eip155:1:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e": { + "uid": "eip155:1:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "address": "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "chain_id": 1, + "classification": "wallet" + } + } + } +} \ No newline at end of file diff --git a/apps/authz/src/app/opa/rego/lib/input.json b/apps/authz/src/app/opa/rego/lib/input.json new file mode 100644 index 000000000..a7445df3b --- /dev/null +++ b/apps/authz/src/app/opa/rego/lib/input.json @@ -0,0 +1,52 @@ +{ + "action": "signTransaction", + "principal": {"uid": "test-bob-uid"}, + "resource": {"uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e"}, + "request": { + "type": "eip1559", + "chain_id": 137, + "max_fee_per_gas": "20000000000", + "max_priority_fee_per_gas": "3000000000", + "gas": "21000", + "nonce": 1, + "from": "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "to": "0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3" + }, + "intent": { + "type": "transferToken", + "from": { + "uid": "eip155:eoa:0xddcf208f219a6e6af072f2cfdc615b2c1805f98e", + "address": "0xddcf208f219a6e6af072f2cfdc615b2c1805f98e" + }, + "to": { + "uid": "eip155:137:0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3", + "chain_id": 137, + "address": "0xa45e21e9370ba031c5e1f47dedca74a7ce2ed7a3" + }, + "amount": 1000000000000000000, + "token": { + "uid": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "address": "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", + "chainId": 137, + "classification": "internal" + } + }, + "signatures": [ + { + "signer": "test-bob-uid", + "hash": "0x894ee391f2fb86469042159c46084add956d1d1f997bb4c43d9c8d2a52970a615b790c416077ec5d199ede5ae0fc925859c80c52c5c74328e25d9e9d5195e3981c" + }, + { + "signer": "test-alice-uid", + "hash": "0x894ee391f2fb86469042159c46084add956d1d1f997bb4c43d9c8d2a52970a615b790c416077ec5d199ede5ae0fc925859c80c52c5c74328e25d9e9d5195e3981c" + }, + { + "signer": "test-foo-uid", + "hash": "0x894ee391f2fb86469042159c46084add956d1d1f997bb4c43d9c8d2a52970a615b790c416077ec5d199ede5ae0fc925859c80c52c5c74328e25d9e9d5195e3981c" + }, + { + "signer": "0xaaa8ee1cbaa1856f4550c6fc24abb16c5c9b2a43", + "hash": "0x894ee391f2fb86469042159c46084add956d1d1f997bb4c43d9c8d2a52970a615b790c416077ec5d199ede5ae0fc925859c80c52c5c74328e25d9e9d5195e3981c" + } + ] +} diff --git a/apps/authz/src/app/opa/rego/lib/main.rego b/apps/authz/src/app/opa/rego/lib/main.rego new file mode 100644 index 000000000..5e3a3f655 --- /dev/null +++ b/apps/authz/src/app/opa/rego/lib/main.rego @@ -0,0 +1,49 @@ +package main + +import future.keywords.in + +default evaluate := { + "permit": false, + "reasons": set(), + # The default flag indicates whether the rule was evaluated as expected or if + # it fell back to the default value. It also helps identify cases of what we + # call "implicit deny" in the legacy policy engine. + "default": true, +} + +evaluate := decision { + confirm_set := {p | p = permit[_]} + forbid_set := {f | f = forbid[_]} + count(confirm_set) > 0 + count(forbid_set) == 0 + + # If ALL Approval in confirm_set has count(approval.approvalsMissing) == 0, set "permit": true. + # We "Stack" approvals, so multiple polices that match & each have different requirements, ALL must succeed. + # If you want to avoid this, the rules should get upper bounded so they're mutually exlusive, but that's done at the policy-builder time, not here. + + # Filter confirm_set to only include objects where approvalsMissing is empty + filtered_confirm_set := {p | p = confirm_set[_]; count(p.approvalsMissing) == 0} + + decision := { + "permit": count(filtered_confirm_set) == count(confirm_set), + "reasons": confirm_set, + } +} + +evaluate := decision { + permit_set := {p | p = permit[_]} + forbid_set := {f | f = forbid[_]} + + # If the forbid set is not empty, set "permit": false. + count(forbid_set) > 0 + + # TODO: forbid rules need the same response structure as permit so we can have the policyId + decision := { + "permit": false, + "reasons": set(), + } +} + +forbid[{"policyId": "test-forbid-policy"}] { + 2 == 1 +} diff --git a/apps/authz/src/app/opa/rego/lib/policies/policy1.rego b/apps/authz/src/app/opa/rego/lib/policies/policy1.rego new file mode 100644 index 000000000..8bdd1a9f4 --- /dev/null +++ b/apps/authz/src/app/opa/rego/lib/policies/policy1.rego @@ -0,0 +1,39 @@ +package main + +import future.keywords.in + +permit[{"policyId": "test-policy-1"}] := reason { + check_principal_id({"test-bob-uid"}) + check_wallet_assignees({"test-bob-uid"}) + check_transfer_token_type({"transferToken"}) + check_transfer_token_address({"0x2791bca1f2de4661ed88a30c99a7a9449aa84174"}) + check_transfer_token_operation({"operator": "eq", "value": 1000000000000000000}) + + approvalsRequired = [ + { + "threshold": 1, + "countPrincipal": true, + "entityType": "Narval::UserGroup", + "entityIds": ["test-user-group-one-uid"], + }, + { + "threshold": 2, + "countPrincipal": true, + "entityType": "Narval::User", + "entityIds": ["test-bob-uid", "test-bar-uid", "test-signer-uid"], + }, + ] + + approvalsResults = [res | + approval := approvalsRequired[_] + res := check_approval(approval) + ] + + approvals := get_approvals_result(approvalsResults) + + reason := { + "policyId": "test-policy-1", + "approvalsSatisfied": approvals.approvalsSatisfied, + "approvalsMissing": approvals.approvalsMissing, + } +} \ No newline at end of file diff --git a/apps/authz/src/app/opa/rego/lib/policies/policy2.rego b/apps/authz/src/app/opa/rego/lib/policies/policy2.rego new file mode 100644 index 000000000..380bbd69d --- /dev/null +++ b/apps/authz/src/app/opa/rego/lib/policies/policy2.rego @@ -0,0 +1,35 @@ +package main + +import future.keywords.in + +permit[{"policyId": "test-policy-2"}] := reason { + check_principal_id({"test-bob-uid"}) + check_wallet_assignees({"test-bob-uid"}) + check_transfer_token_type({"transferToken"}) + check_transfer_token_address({"0x2791bca1f2de4661ed88a30c99a7a9449aa84174"}) + check_transfer_token_operation({"operator": "eq", "value": 1000000000000000000}) + approvalsRequired = [ + { + "threshold": 1, + "countPrincipal": true, + "entityType": "Narval::UserGroup", + "entityIds": ["test-user-group-one-uid"], + }, + { + "threshold": 2, + "countPrincipal": true, + "entityType": "Narval::User", + "entityIds": ["test-bob-uid", "test-bar-uid", "test-signer-uid"], + }, + ] + approvalsResults = [res | + approval := approvalsRequired[_] + res := check_approval(approval) + ] + approvals := get_approvals_result(approvalsResults) + reason := { + "policyId": "test-policy-2", + "approvalsSatisfied": approvals.approvalsSatisfied, + "approvalsMissing": approvals.approvalsMissing, + } +} diff --git a/apps/authz/src/app/opa/rego/lib/policies/policy3.rego b/apps/authz/src/app/opa/rego/lib/policies/policy3.rego new file mode 100644 index 000000000..6862c9c9d --- /dev/null +++ b/apps/authz/src/app/opa/rego/lib/policies/policy3.rego @@ -0,0 +1,30 @@ +package main + +import future.keywords.in + +permit[{"policyId": "test-policy-3"}] := reason { + check_principal_id({"test-bob-uid"}) + check_wallet_assignees({"test-bob-uid"}) + check_transfer_token_type({"transferToken"}) + check_transfer_token_address({"0x2791bca1f2de4661ed88a30c99a7a9449aa84174"}) + check_transfer_token_operation({"operator": "eq", "value": 1000000000000000000}) + + approvalsRequired = [{ + "threshold": 2, + "countPrincipal": false, + "entityType": "Narval::UserRole", + "entityIds": ["admin"], + }] + approvalsResults = [res | + approval := approvalsRequired[_] + res := check_approval(approval) + ] + + approvals := get_approvals_result(approvalsResults) + + reason := { + "policyId": "test-policy-3", + "approvalsSatisfied": approvals.approvalsSatisfied, + "approvalsMissing": approvals.approvalsMissing, + } +} diff --git a/apps/authz/src/app/opa/rego/lib/utils/utils.rego b/apps/authz/src/app/opa/rego/lib/utils/utils.rego new file mode 100644 index 000000000..788a81c1c --- /dev/null +++ b/apps/authz/src/app/opa/rego/lib/utils/utils.rego @@ -0,0 +1,76 @@ +package main + +import future.keywords.in + +wildcard = "*" + +seconds_to_nanoseconds(epoch_s) = epoch_ns { + epoch_ns := epoch_s * 1000000000 +} + +nanoseconds_to_seconds(epoch_ns) = epoch_s { + epoch_s := epoch_ns / 1000000000 +} + +now_s = now { + now := nanoseconds_to_seconds(time.now_ns()) +} + +principal = result { + result := data.entities.users[input.principal.uid] +} + +resource = result { + result := data.entities.wallets[input.resource.uid] +} + +source = result { + result := data.entities.wallets[input.intent.from.uid] +} + +source = result { + result := data.entities.address_book[input.intent.from.uid] +} + +destination = result { + result := data.entities.wallets[input.intent.to.uid] +} + +destination = result { + result := data.entities.address_book[input.intent.to.uid] +} + +principal_groups = result { + result := {group.uid | + group := data.entities.user_groups[_] + input.principal.uid in group.users + } +} + +wallet_groups = result { + result := {group.uid | + group := data.entities.wallet_groups[_] + input.resource.uid in group.wallets + } +} + +signers_roles = result { + result := {user.role | + signature := input.signatures[_] + user := data.entities.users[signature.signer] + } +} + +signers_groups = result { + result := {group.uid | + signature := input.signatures[_] + user := data.entities.users[signature.signer] + group := data.entities.user_groups[_] + user.uid in group.users + } +} + +check_transfer_resource_integrity { + contains(input.resource.uid, input.request.from) + input.resource.uid == input.intent.from.uid +} \ No newline at end of file