Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Added authz-casbin plugin and doc and tests for it #4710

Merged
merged 16 commits into from
Aug 6, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ jobs:
tar zxvf ${{ steps.branch_env.outputs.fullname }}

- name: Linux Get dependencies
run: sudo apt install -y cpanminus build-essential libncurses5-dev libreadline-dev libssl-dev perl
run: sudo apt install -y cpanminus build-essential libncurses5-dev libreadline-dev libssl-dev perl libpcre3 libpcre3-dev
Yiyiyimu marked this conversation as resolved.
Show resolved Hide resolved

- name: Linux Before install
run: sudo ./ci/${{ matrix.os_name }}_runner.sh before_install
Expand Down
347 changes: 347 additions & 0 deletions apisix/plugins/authz-casbin.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
--
-- Licensed to the Apache Software Foundation (ASF) under one or more
-- contributor license agreements. See the NOTICE file distributed with
-- this work for additional information regarding copyright ownership.
-- The ASF licenses this file to You under the Apache License, Version 2.0
-- (the "License"); you may not use this file except in compliance with
-- the License. You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--

local casbin = require("casbin")
local core = require("apisix.core")
local plugin = require("apisix.plugin")
local plugin_metadata = require("apisix.admin.plugin_metadata")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plugin_metadata should be only set on the CP side. It's forbidden to let configuration flow from the DP to the CP.

local ngx = ngx
local get_headers = ngx.req.get_headers

local casbin_enforcer

local plugin_name = "authz-casbin"

local schema = {
type = "object",
properties = {
model_path = { type = "string" },
policy_path = { type = "string" },
username = { type = "string"}
},
required = {"model_path", "policy_path", "username"},
additionalProperties = false
}

local metadata_schema = {
type = "object",
properties = {
model = {type = "string"},
policy = {type = "string"},
},
required = {"model", "policy"},
additionalProperties = false
}

local _M = {
version = 0.1,
priority = 2560,
name = plugin_name,
schema = schema,
metadata_schema = metadata_schema
}

function _M.check_schema(conf, schema_type)
if schema_type == core.schema.TYPE_METADATA then
return core.schema.check(metadata_schema, conf)
end
local ok, err = core.schema.check(schema, conf)
if ok then
return true
else
local metadata = plugin.plugin_metadata(plugin_name)
if metadata and metadata.value.model and metadata.value.policy and conf.username then
rushitote marked this conversation as resolved.
Show resolved Hide resolved
return true
end
end
return false, err
end


local function new_enforcer(model_path, policy_path)
local e

if model_path and policy_path then
e = casbin:new(model_path, policy_path)
e.type = "file"
end

local metadata = plugin.plugin_metadata(plugin_name)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO, it surprises me that the enforcer from metadata can override the one from the route. Normally, the route level configuration can override the global one.

if metadata and metadata.value.model and metadata.value.policy then
local model = metadata.value.model
local policy = metadata.value.policy
e = casbin:newEnforcerFromText(model, policy)
e.type = "metadata"
end

return e
end


function _M.rewrite(conf)
rushitote marked this conversation as resolved.
Show resolved Hide resolved
-- creates an enforcer when request sent for the first time
if not casbin_enforcer then
casbin_enforcer = new_enforcer(conf.model_path, conf.policy_path)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple routes will use the same casbin enforcer? I think we should store different casbin via lrucache, like this:

config, err = lrucache(plugin_name, metadata.modifiedIndex, update_filter, metadata.value)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you are right. I have changed it now.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I rethought about it afternoon, and found it isn't a good idea.
It is more suitable to bind the casbin with the conf like:

conf.block_rules_concat = core.table.concat(block_rules, "|")

We also need to ensure the global casbin is update-to-date with the metadata via #4710 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@spacewander Sorry, I found this now. But is there any reason for us to not use lrucache? It worked well for two different routes with different casbin enforcers when I tested it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed lrucache and replaced it by binding the casbin_enforcer to conf. Also we will use the modifiedIndex to keep the casbin from metadata updated.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@spacewander Sorry, I found this now. But is there any reason for us to not use lrucache? It worked well for two different routes with different casbin enforcers when I tested it.

It works fine, but makes the life cycle complex.

end

local path = ngx.var.request_uri
rushitote marked this conversation as resolved.
Show resolved Hide resolved
local method = ngx.var.request_method
rushitote marked this conversation as resolved.
Show resolved Hide resolved
local username = get_headers()[conf.username]
rushitote marked this conversation as resolved.
Show resolved Hide resolved
if not username then username = "anonymous" end

if path and method and username then
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to check the existence for these three variables if we use them in the HTTP sub-system.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, right - this is redundant here.

if not casbin_enforcer:enforce(username, path, method) then
return 403, {message = "Access Denied"}
end
else
return 403, {message = "Access Denied"}
end
end


local function save_policy()
if not casbin_enforcer then
return 400, {message = "Enforcer not created yet."}
end

if casbin_enforcer.type == "metadata" then
local metadata = plugin.plugin_metadata(plugin_name)
local conf = {
model = metadata.value.model,
policy = casbin_enforcer.model:savePolicyToText()
}

local ok, err = plugin_metadata.put(plugin_name, conf)
if not ok then
core.log.error("Save Policy error: " .. err)
return 400, {message = "Failed to save policy, see logs."}
else
return 200
end
else
local _, err = pcall(function ()
casbin_enforcer:savePolicy()
end)
if not err then
return 200, {message = "Successfully saved policy."}
else
core.log.error("Save Policy error: " .. err)
return 400, {message = "Failed to save policy, see logs."}
end
end
end


local function add_policy()
if not casbin_enforcer then
return 400, {message = "Enforcer not created yet."}
end

local headers = get_headers()
local type = headers["type"]

if type == "p" then
local subject = headers["subject"]
local object = headers["object"]
local action = headers["action"]

if not subject or not object or not action then
return 400, {message = "Invalid policy request."}
end

if casbin_enforcer:AddPolicy(subject, object, action) then
local ok, _ = save_policy()
if ok == 400 then
return 400, {message = "Failed to save policy, see logs."}
end
return 200, {message = "Successfully added policy."}
else
return 400, {message = "Invalid policy request."}
end
elseif type == "g" then
local user = headers["user"]
local role = headers["role"]

if not user or not role then
return 400, {message = "Invalid policy request."}
end

if casbin_enforcer:AddGroupingPolicy(user, role) then
local ok, _ = save_policy()
if ok == 400 then
return 400, {message = "Failed to save policy, see logs."}
end
return 200, {message = "Successfully added grouping policy."}
else
return 400, {message = "Invalid policy request."}
end
else
return 400, {message = "Invalid policy type."}
end
end


local function remove_policy()
if not casbin_enforcer then
return 400, {message = "Enforcer not created yet."}
end

local headers = get_headers()
local type = headers["type"]

if type == "p" then
local subject = headers["subject"]
local object = headers["object"]
local action = headers["action"]

if not subject or not object or not action then
return 400, {message = "Invalid policy request."}
end

if casbin_enforcer:RemovePolicy(subject, object, action) then
local ok, _ = save_policy()
if ok == 400 then
return 400, {message = "Failed to save policy, see logs."}
end
return 200, {message = "Successfully removed policy."}
else
return 400, {message = "Invalid policy request."}
end
elseif type == "g" then
local user = headers["user"]
local role = headers["role"]

if not user or not role then
return 400, {message = "Invalid policy request."}
end

if casbin_enforcer:RemoveGroupingPolicy(user, role) then
local ok, _ = save_policy()
if ok == 400 then
return 400, {message = "Failed to save policy, see logs."}
end
return 200, {message = "Successfully removed grouping policy."}
else
return 400, {message = "Invalid policy request."}
end
else
return 400, {message = "Invalid policy type."}
end
end


local function has_policy()
if not casbin_enforcer then
return 400, {message = "Enforcer not created yet."}
end

local headers = get_headers()
local type = headers["type"]

if type == "p" then
local subject = headers["subject"]
local object = headers["object"]
local action = headers["action"]

if not subject or not object or not action then
return 400, {message = "Invalid policy request."}
end

if casbin_enforcer:HasPolicy(subject, object, action) then
return 200, {data = "true"}
else
return 200, {data = "false"}
end
elseif type == "g" then
local user = headers["user"]
local role = headers["role"]

if not user or not role then
return 400, {message = "Invalid policy request."}
end

if casbin_enforcer:HasGroupingPolicy(user, role) then
return 200, {data = "true"}
else
return 200, {data = "false"}
end
else
return 400, {message = "Invalid policy type."}
end
end


local function get_policy()
if not casbin_enforcer then
return 400, {message = "Enforcer not created yet."}
end

local headers = get_headers()
local type = headers["type"]

if type == "p" then
local policy = casbin_enforcer:GetPolicy()
if policy then
return 200, {data = policy}
else
return 400
end
elseif type == "g" then
local groupingPolicy = casbin_enforcer:GetGroupingPolicy()
if groupingPolicy then
return 200, {data = groupingPolicy}
else
return 400
end
else
return 400, {message = "Invalid policy type."}
end
end


function _M.api()
return {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we already use metadata to store the configuration, there is no need to provide APIs on the DP side. Everything should be done with the plugin metadata's API on the CP side.

Copy link
Contributor Author

@rushitote rushitote Jul 30, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@spacewander
Thanks for your review, so we will remove all the API functions and if the model/policy text in plugin metadata is updated (from the CP side), we will update the casbin_enforcer through that model/policy too. Is this correct?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you can use metadata.modifiedIndex to ensure casbin_enforcer is created with the latest model/policy, like this one:

if not cached_version or cached_version ~= user_routes.conf_version then
uri_router = base_router.create_radixtree_uri_router(user_routes.values,
uri_routes, false)
cached_version = user_routes.conf_version
end

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And I think you can submit a minimum viable pull request so that we can check & merge it early.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure I will do that.

Copy link
Contributor Author

@rushitote rushitote Jul 30, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a condition that if the configuration changes, the casbin_enforcer will be recreated.

{
methods = {"POST"},
uri = "/apisix/plugin/authz-casbin/add",
handler = add_policy,
},
{
methods = {"POST"},
uri = "/apisix/plugin/authz-casbin/remove",
handler = remove_policy,
},
{
methods = {"GET"},
uri = "/apisix/plugin/authz-casbin/has",
handler = has_policy,
},
{
methods = {"GET"},
uri = "/apisix/plugin/authz-casbin/get",
handler = get_policy,
},
{
methods = {"POST"},
uri = "/apisix/plugin/authz-casbin/save",
handler = save_policy,
},
}
end


return _M
2 changes: 1 addition & 1 deletion ci/centos7-ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ install_dependencies() {

# install openresty to make apisix's rpm test work
yum install -y yum-utils && yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo
yum install -y openresty openresty-debug openresty-openssl111-debug-devel
yum install -y openresty openresty-debug openresty-openssl111-debug-devel pcre pcre-devel

# install luarocks
./utils/linux-install-luarocks.sh
Expand Down
1 change: 1 addition & 0 deletions conf/config-default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ plugins: # plugin list (sorted by priority)
- uri-blocker # priority: 2900
- request-validation # priority: 2800
- openid-connect # priority: 2599
- authz-casbin # priority: 2560
- wolf-rbac # priority: 2555
- hmac-auth # priority: 2530
- basic-auth # priority: 2520
Expand Down
Loading