Skip to content
This repository has been archived by the owner on Oct 8, 2021. It is now read-only.

External authorization #76

Open
wants to merge 4 commits into
base: ostia-architecture
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 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 auth-ruby/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.ruby-version
64 changes: 47 additions & 17 deletions auth-ruby/examples/config.yml
Original file line number Diff line number Diff line change
@@ -1,35 +1,65 @@
localhost:8000:
app:3000:
enabled: true

identity:
- oidc:
name: test
endpoint: http://localhost:8080/auth/realms/test

- oidc:
name: demo
endpoint: https://localhost:8443/realms/demo/

- oidc:
name: admin
endpoint: https://localhost:8443/realms/admin/
name: keycloak
endpoint: http://keycloak:8080/auth/realms/ostia

metadata:
- userinfo:
oidc: test
client_id: test
client_secret: test
oidc: keycloak
client_id: auth-ruby
client_secret: 2e5246f2-f4ef-4d55-8225-36e725071dee

authorization:
- opa:
uuid: 8fa79d93-0f93-4e23-8c2a-666be266cad1
endpoint: 'http://opa-service:8181'
rego: |
allow {
input.method == "PUT"
http_request.method == "GET"
path_arr = ["pets"]
}

allow {
http_request.method == "POST"
path_arr = ["pets"]
}

allow {
http_request.method == "GET"
own_resource
}

allow {
http_request.method == "PUT"
own_resource
}

allow {
http_request.method == "DELETE"
own_resource
}

allow {
http_request.method == "GET"
path_arr = ["pets", "stats"]
is_admin
}

own_resource {
some petid
input.path = ["pets", petid]
input.user == input.owner
path_arr = ["pets", petid]
subject := object.get(identity, "sub", object.get(identity, "username", ""))
subject == object.get(resource, "owner", "")
}

is_admin {
identity.realm_access.roles[_] == "admin"
}
- jwt:
enabled: false
match:
http:
path: '/api/*'
Expand Down
36 changes: 33 additions & 3 deletions auth-ruby/src/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class V2AuthorizationService
attr_reader :config
def initialize(config)
@config = config
@registry = PolicyRegistry.setup!(config)
end

include GRPC::GenericService
Expand All @@ -48,28 +49,40 @@ def initialize(config)
rpc :Check, Envoy::Service::Auth::V2::CheckRequest, Envoy::Service::Auth::V2::CheckResponse

class Context
attr_reader :identity, :metadata
attr_reader :identity, :metadata, :authorization
attr_reader :request, :service

def initialize(request, service)
@request = request
@service = service
@identity = {}
@metadata = {}
@authorization = {}
end

def evaluate!
proc = ->(obj, result) { result[obj] = obj.call(self) }

service.identity.each_with_object(identity, &proc)
service.metadata.each_with_object(metadata, &proc)
service.authorization.each_with_object(authorization, &proc)

@identity.freeze
@metadata.freeze
@authorization.freeze
end

def valid?
identity.values.any?
identity.values.any? && authorization.select { |config, _| config.enabled? }.values.all?(&:authorized?)
end

def to_h
{
request: request,
service: service,
identity: identity.transform_keys(&:name).transform_values(&:to_h),
metadata: metadata.transform_keys{ |key| key.class.to_s.demodulize.underscore }
}.transform_values(&:to_h)
end
end

Expand Down Expand Up @@ -100,7 +113,6 @@ def check(req, rest)

protected


def ok_response(req, service)
Envoy::Service::Auth::V2::CheckResponse.new(
status: Google::Rpc::Status.new(code: GRPC::Core::StatusCodes::OK),
Expand Down Expand Up @@ -136,6 +148,24 @@ def request_response(request:, call:, method:)
end
end

class PolicyRegistry
def self.setup!(config)
new(config).setup!
end

def initialize(config)
@config = config
end

attr_reader :config

def setup!
config.each_host.flat_map(&:authorization).map do |authorization|
authorization.try(:register!)
end
end
end

def main
port = "0.0.0.0:#{ENV.fetch('PORT', 50051)}"
config = Config.new ENV.fetch('CONFIG', 'examples/config.yml')
Expand Down
8 changes: 8 additions & 0 deletions auth-ruby/src/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ def for_host(name)
end
end

def host_names
@store.transaction(true) { @store.roots }
end

def each_host(&block)
host_names.map(&method(:for_host)).map(&block)
end

module BuildSubclass
class AmbiguousKeysError < StandardError; end

Expand Down
117 changes: 117 additions & 0 deletions auth-ruby/src/config/authorization.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,127 @@
# frozen_string_literal: true

require 'net/http'
require 'uri'
require 'json'

class Config::Authorization < OpenStruct
extend Config::BuildSubclass

class Response < OpenStruct
def authorized?
raise NotImplementedError, __method__
end
end

class OPA < self
DEFAULTS = {
endpoint: 'http://localhost:8181',
data_api: '/v1/data/ostia/authz',
policy_api: '/v1/policies'
}

class RegisterException < StandardError; end

class Response < Config::Authorization::Response
def authorized?
result['allow']
end
end

def register!
auth_request = Net::HTTP::Put.new(policy_uri, 'Content-Type' => 'text/plain')
auth_request.body = rego_policy
auth_response = Net::HTTP.start(policy_uri.hostname, policy_uri.port) do |http|
http.request(auth_request)
end

case auth_response
when Net::HTTPOK
JSON.parse(auth_response.body)
else
raise RegisterException, auth_response
end
rescue RegisterException, Net::HTTPError
self.enabled = false
nil
end

def call(context)
return unless enabled?

auth_request = Net::HTTP::Post.new(data_uri, 'Content-Type' => 'application/json')
request, identity, metadata = context.to_h.values_at(:request, :identity, :metadata)
auth_request.body = { input: request.merge(context: { identity: identity.values.first, metadata: metadata }) }.to_json
puts "[OPA] #{auth_request.body}"
auth_response = Net::HTTP.start(data_uri.hostname, data_uri.port) do |http|
http.request(auth_request)
end

case auth_response
when Net::HTTPOK
response_json = case auth_response['content-type']
when 'application/json'
JSON.parse(auth_response.body)
else
{ allowed: true, message: auth_response.body }
end
Response.new(response_json)
end
end

protected

DEFAULTS.keys.each do |attribute|
define_method(attribute) do
super() || DEFAULTS[attribute]
end
end

alias _endpoint endpoint

def endpoint
URI.parse(_endpoint)
end

def data_uri
uri = endpoint
uri.path = [data_api, uuid].join('/')
uri
end

def policy_uri
uri = endpoint
uri.path = [policy_api, uuid].join('/')
uri
end

def rego_policy
<<~REGO
package ostia.authz["#{uuid}"]

import input.attributes.request.http as http_request
import input.context.identity
import input.context.metadata

resource = object.get(input.context, "resource", {})
path_arr = split(trim_left(http_request.path, "/"), "/")

default allow = false

#{rego}
REGO
end
end

class JWT < self
def call(context)
return unless enabled?

# TODO
end
end

def enabled?
enabled.nil? || !!enabled
end
end
6 changes: 5 additions & 1 deletion auth-ruby/src/config/identity/oidc.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ def endpoint
end

# not in the RFC, but keycloak has it
OpenIDConnect::Discovery::Provider::Config::Response.attr_optional :token_introspection_endpoint
OpenIDConnect::Discovery::Provider::Config::Response.attr_optional :token_introspection_endpoint, :introspection_endpoint
OpenIDConnect::ResponseObject::IdToken.attr_optional :realm_access, :resource_access, :scope, :email_verified, :preferred_username, :email

class Config::Identity::OIDC < Config::Identity
def config
Expand Down Expand Up @@ -56,6 +57,9 @@ def to_s
@token
end

delegate :raw_attributes, to: :@decoded, allow_nil: true
alias to_h raw_attributes # because OpenIDConnect::ResponseObject::IdToken#as_json will only return string values

private def method_missing(symbol, *args, &block)
return super unless @decoded
@decoded.public_send(symbol, *args, &block)
Expand Down
2 changes: 1 addition & 1 deletion auth-ruby/src/config/metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def call(context)
id = context.identity.fetch(oidc) { return }
puts id

uri = URI(oidc.config.token_introspection_endpoint)
uri = URI(oidc.config.token_introspection_endpoint || oidc.config.introspection_endpoint)
uri.user = client_id
uri.password = client_secret

Expand Down
36 changes: 36 additions & 0 deletions auth-ruby/test/config/test_authorization.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

require 'minitest/autorun'
require_relative '../test_helper'
require 'json'

describe Config::Authorization do
let(:authorization_config) { YAML.load(file_fixture('config.yml').read).dig('localhost:8000', 'authorization') }

describe 'opa' do
let(:described_class) { Config::Authorization::OPA }
let(:input) { JSON.parse(file_fixture('opa_input_forbidden.json').read)['input'] }
let(:config) { authorization_config.first['opa'] }
let(:context) do
OpenStruct.new(to_h: {
request: input.slice('attributes'),
identity: { 'test' => input.dig('context', 'identity') },
metadata: input.dig('context', 'metadata')
})
end

subject do
described_class.new(config)
end

before do
subject.register!
end

it 'wraps the response' do
result = subject.call(context)
expect(result).must_be_instance_of(described_class::Response)
refute(result.authorized?) # user does not have permission
end
end
end
Loading