local M = {}
function M.client(read_url, opts)
opts = opts or {}
local read = read_url:gsub("/+$", "")
local write = opts.write_url and opts.write_url:gsub("/+$", "") or nil
local function urlencode(s)
return (tostring(s):gsub("([^%w%-%.%_%~])", function(ch)
return string.format("%%%02X", string.byte(ch))
end))
end
local function build_query(params)
local parts = {}
for k, v in pairs(params) do
if v ~= nil and v ~= "" then
parts[#parts + 1] = urlencode(k) .. "=" .. urlencode(v)
end
end
if #parts == 0 then return "" end
return "?" .. table.concat(parts, "&")
end
local function read_get(path_str)
local resp = http.get(read .. path_str)
if resp.status ~= 200 then
error("keto: GET " .. path_str .. " HTTP " .. resp.status .. ": " .. resp.body)
end
return json.parse(resp.body)
end
local function require_write()
if not write then
error("keto: write_url not configured — pass opts.write_url to keto.client()")
end
end
local function build_check_params(namespace_or_table, object, relation, subject)
local params
if type(namespace_or_table) == "table" then
local t = namespace_or_table
params = {
namespace = t.namespace,
object = t.object,
relation = t.relation,
}
local subj = t.subject_id or t.subject
if type(subj) == "string" then
params.subject_id = subj
elseif type(subj) == "table" then
params.subject_set_namespace = subj.namespace
params.subject_set_object = subj.object
params.subject_set_relation = subj.relation
end
else
params = {
namespace = namespace_or_table,
object = object,
relation = relation,
}
if type(subject) == "string" then
params.subject_id = subject
elseif type(subject) == "table" then
params.subject_set_namespace = subject.namespace
params.subject_set_object = subject.object
params.subject_set_relation = subject.relation
end
end
return params
end
local c = {}
c.tuples = {}
function c.tuples:list(filters)
return read_get("/relation-tuples" .. build_query(filters or {}))
end
function c.tuples:create(tuple)
require_write()
local resp = http.put(write .. "/admin/relation-tuples", tuple)
if resp.status ~= 201 and resp.status ~= 200 then
error("keto: create tuple HTTP " .. resp.status .. ": " .. resp.body)
end
end
function c.tuples:delete(tuple)
require_write()
local params = {
namespace = tuple.namespace,
object = tuple.object,
relation = tuple.relation,
}
if tuple.subject_id then
params.subject_id = tuple.subject_id
elseif tuple.subject_set then
params.subject_set_namespace = tuple.subject_set.namespace
params.subject_set_object = tuple.subject_set.object
params.subject_set_relation = tuple.subject_set.relation
end
local resp = http.delete(write .. "/admin/relation-tuples" .. build_query(params))
if resp.status ~= 204 and resp.status ~= 200 then
error("keto: delete tuple HTTP " .. resp.status .. ": " .. resp.body)
end
end
function c.tuples:delete_all(filters)
require_write()
local resp = http.delete(write .. "/admin/relation-tuples" .. build_query(filters))
if resp.status ~= 204 and resp.status ~= 200 then
error("keto: delete_all HTTP " .. resp.status .. ": " .. resp.body)
end
end
c.permissions = {}
function c.permissions:check(namespace_or_table, object, relation, subject)
local params = build_check_params(namespace_or_table, object, relation, subject)
local resp = http.get(read .. "/relation-tuples/check" .. build_query(params))
if resp.status == 200 then
local data = json.parse(resp.body)
return data.allowed == true
elseif resp.status == 403 then
return false
end
error("keto: check failed HTTP " .. resp.status .. ": " .. resp.body)
end
function c.permissions:batch_check(tuples)
local results = {}
for _, t in ipairs(tuples) do
local params = build_check_params(t)
local resp = http.get(read .. "/relation-tuples/check" .. build_query(params))
if resp.status == 200 then
local data = json.parse(resp.body)
results[#results + 1] = data.allowed == true
elseif resp.status == 403 then
results[#results + 1] = false
else
error("keto: batch_check failed HTTP " .. resp.status .. ": " .. resp.body)
end
end
return results
end
function c.permissions:expand(namespace, object, relation, depth)
local params = {
namespace = namespace,
object = object,
relation = relation,
["max-depth"] = tostring(depth or 3),
}
return read_get("/relation-tuples/expand" .. build_query(params))
end
c.roles = {}
function c.roles:user_roles(user_id, namespace)
local subject = user_id
if not user_id:match("^user:") then
subject = "user:" .. user_id
end
local result = c.tuples:list({
namespace = namespace or "Role",
relation = "members",
subject_id = subject,
})
local roles = {}
for _, tuple in ipairs(result.relation_tuples or {}) do
roles[#roles + 1] = { object = tuple.object, relation = tuple.relation }
end
return roles
end
function c.roles:has_any(user_id, role_objects, namespace)
local roles = c.roles:user_roles(user_id, namespace)
local set = {}
for _, r in ipairs(roles) do set[r.object] = true end
for _, target in ipairs(role_objects) do
if set[target] then return true end
end
return false
end
return c
end
return M