Skip to content

Commit 0016e5e

Browse files
committed
feat: scopes
1 parent ab0553b commit 0016e5e

21 files changed

+255
-0
lines changed

doc/scopes.md

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Scopes
2+
3+
Scopes are used to restrict access to endpoints. You can, optionally, assign scopes to endpoints and the authenticator will be responsible to ensuring are satisified for the authenticating entity before allowing the request to proceed.
4+
5+
## Configuring endpoints
6+
7+
You should add a list of supported scopes to each endpoint. If an endpoint doesn't specify any scopes it will always be permitted. If you specify multiple scopes, possession of any of scopes will allow the endpoint to be executed.
8+
9+
```ruby
10+
endpoint :list do
11+
name 'List all widgets'
12+
scopes 'widgets', 'widgets:read'
13+
# ... rest of the method
14+
end
15+
```
16+
17+
## Configuring the authenticator
18+
19+
The authenticator is responsible for determining whether or not an authenticated identity possesses a scope. You do this by defining a `scope_validator` which contains the logic required to determine whether the given scope is available or not.
20+
21+
```ruby
22+
module CoreAPI
23+
class Authenticator < Rapid::Authenticator
24+
25+
# ...
26+
27+
scope_validator do |scope|
28+
request.identity.has_scope?(scope)
29+
end
30+
31+
end
32+
end
33+
```
34+
35+
## Providing scope details
36+
37+
The schema will contain a list of all scopes that are available within the API. You can decorate this by providing a description for each scope as necessary.
38+
39+
```ruby
40+
module CoreAPI
41+
class Base < Rapid::API
42+
43+
# ...
44+
45+
scopes do
46+
add "widgets", "Full access to all widgets"
47+
add "widgets:read", "Read-only access to all widgets"
48+
add "self", "Access to user details"
49+
end
50+
51+
end
52+
end
53+
```

examples/core_api/base.rb

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ class Base < Rapid::API
88

99
authenticator MainAuthenticator
1010

11+
scopes do
12+
add 'time', 'Allows time telling functions'
13+
end
14+
1115
routes do
1216
schema
1317

examples/core_api/controllers/time_controller.rb

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class TimeController < Rapid::Controller
1313
endpoint :now do
1414
description 'Returns the current time'
1515
field :time, type: Objects::Time, include: 'unix,day_of_week'
16+
scope 'time'
1617
action do |_request, response|
1718
time = Time.now
1819
response.add_field :time, time

lib/rapid/authenticator.rb

+11
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ def execute(environment)
3939
environment.call(&definition.action)
4040
end
4141

42+
# If any of the given scopes are valid
43+
#
44+
# @param scope [String]
45+
# @return [Boolean]
46+
def authorized_scope?(scopes)
47+
return true if definition.scope_validator.nil?
48+
return true if scopes.empty?
49+
50+
scopes.any? { |s| definition.scope_validator.call(s) }
51+
end
52+
4253
end
4354

4455
end

lib/rapid/definitions/api.rb

+2
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ class API < Definition
1313
attr_reader :controllers
1414
attr_reader :route_set
1515
attr_reader :exception_handlers
16+
attr_reader :scopes
1617

1718
def setup
1819
@route_set = RouteSet.new
1920
@controllers = {}
2021
@exception_handlers = HookSet.new
22+
@scopes = {}
2123
end
2224

2325
def dsl

lib/rapid/definitions/authenticator.rb

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class Authenticator < Definition
1111

1212
attr_accessor :type
1313
attr_accessor :action
14+
attr_accessor :scope_validator
1415
attr_reader :potential_errors
1516

1617
def setup

lib/rapid/definitions/endpoint.rb

+2
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ class Endpoint < Definition
1616
attr_accessor :http_status
1717
attr_accessor :paginated_field
1818
attr_reader :fields
19+
attr_reader :scopes
1920

2021
def setup
2122
@fields = FieldSet.new
2223
@http_status = 200
24+
@scopes = []
2325
end
2426

2527
def argument_set

lib/rapid/dsls/api.rb

+8
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require 'rapid/dsl'
44
require 'rapid/helpers'
5+
require 'rapid/dsls/scope_descriptions'
56

67
module Rapid
78
module DSLs
@@ -24,6 +25,13 @@ def routes(&block)
2425
@definition.route_set.dsl.instance_eval(&block) if block_given?
2526
end
2627

28+
def scopes(&block)
29+
return unless block_given?
30+
31+
dsl = DSLs::ScopeDescriptions.new(@definition)
32+
dsl.instance_eval(&block)
33+
end
34+
2735
end
2836
end
2937
end

lib/rapid/dsls/authenticator.rb

+9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require 'rapid/dsl'
44
require 'rapid/helpers'
5+
require 'rapid/errors/scope_not_granted_error'
56

67
module Rapid
78
module DSLs
@@ -24,6 +25,14 @@ def action(&block)
2425
@definition.action = block
2526
end
2627

28+
def scope_validator(&block)
29+
unless @definition.potential_errors.include?(Rapid::ScopeNotGrantedError)
30+
potential_error Rapid::ScopeNotGrantedError
31+
end
32+
33+
@definition.scope_validator = block
34+
end
35+
2736
end
2837
end
2938
end

lib/rapid/dsls/endpoint.rb

+10
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ def field(name, *args, type: nil, **options, &block)
6464
super(name, *args, type: type, **options, &block)
6565
end
6666

67+
def scopes(*names)
68+
names.each { |name| scope(name) }
69+
end
70+
71+
def scope(name)
72+
return if @definition.scopes.include?(name.to_s)
73+
74+
@definition.scopes << name.to_s
75+
end
76+
6777
end
6878
end
6979
end

lib/rapid/dsls/scope_descriptions.rb

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
module Rapid
4+
module DSLs
5+
class ScopeDescriptions
6+
7+
def initialize(api)
8+
@api = api
9+
end
10+
11+
def add(name, description)
12+
@api.scopes[name.to_s] = { description: description }
13+
end
14+
15+
end
16+
end
17+
end

lib/rapid/endpoint.rb

+6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require 'rapid/defineable'
55
require 'rapid/definitions/endpoint'
66
require 'rapid/request_environment'
7+
require 'rapid/errors/scope_not_granted_error'
78

89
module Rapid
910
class Endpoint
@@ -49,6 +50,11 @@ def execute(request)
4950
request.authenticator = definition.authenticator || request.controller&.definition&.authenticator || request.api&.definition&.authenticator
5051
request.authenticator&.execute(environment)
5152

53+
# Determine if we're permitted to run the action based on the endpoint's scopes
54+
if request.authenticator && !request.authenticator.authorized_scope?(definition.scopes)
55+
environment.raise_error Rapid::ScopeNotGrantedError, scopes: definition.scopes
56+
end
57+
5258
# Process arguments into the request. This happens after the authentication
5359
# stage because a) authenticators shouldn't be using endpoint specific args
5460
# and b) the argument conditions may need to know the identity.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
require 'rapid/error'
4+
5+
module Rapid
6+
class ScopeNotGrantedError < Rapid::Error
7+
8+
code :scope_not_granted
9+
http_status 403
10+
description 'The scope required for this endpoint has not been granted to the authenticating identity'
11+
12+
field :scopes, [:string]
13+
14+
end
15+
end

lib/rapid/schema/api_schema_type.rb

+6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require 'rapid/schema/api_controller_schema_type'
66
require 'rapid/schema/object_schema_polymorph'
77
require 'rapid/schema/route_set_schema_type'
8+
require 'rapid/schema/scope_type'
89

910
module Rapid
1011
module Schema
@@ -31,6 +32,11 @@ class APISchemaType < Rapid::Object
3132
end
3233

3334
field :route_set, type: RouteSetSchemaType
35+
field :scopes, type: [ScopeType] do
36+
backend do |api|
37+
api.scopes.map { |k, v| v.merge(name: k) }
38+
end
39+
end
3440

3541
end
3642
end

lib/rapid/schema/endpoint_schema_type.rb

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class EndpointSchemaType < Rapid::Object
2828
field :potential_errors, type: [:string] do
2929
backend { |a| a.potential_errors.map { |e| e.definition.id } }
3030
end
31+
field :scopes, type: [:string]
3132

3233
end
3334
end

lib/rapid/schema/scope_type.rb

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
require 'rapid/object'
4+
5+
module Rapid
6+
module Schema
7+
class ScopeType < Rapid::Object
8+
9+
field :name, :string
10+
field :description, :string
11+
12+
end
13+
end
14+
end

spec/specs/rapid/authenticator_spec.rb

+28
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,32 @@
4141
expect(response.headers['x-executed']).to eq '123'
4242
end
4343
end
44+
45+
context '.authorized_scope?' do
46+
it 'returns true if any of the scopes are valid' do
47+
auth = Rapid::Authenticator.create('ExampleAuthenticator') do
48+
scope_validator { |s| s == 'example' }
49+
end
50+
expect(auth.authorized_scope?(%w[example another])).to be true
51+
end
52+
53+
it 'returns true if no scopes are provided' do
54+
auth = Rapid::Authenticator.create('ExampleAuthenticator') do
55+
scope_validator { |s| s == 'example' }
56+
end
57+
expect(auth.authorized_scope?([])).to be true
58+
end
59+
60+
it 'returns true if there is no scope validator for the authenticator' do
61+
auth = Rapid::Authenticator.create('ExampleAuthenticator')
62+
expect(auth.authorized_scope?(['example'])).to be true
63+
end
64+
65+
it 'returns false if none of the scopes are valid' do
66+
auth = Rapid::Authenticator.create('ExampleAuthenticator') do
67+
scope_validator { |s| s == 'example' }
68+
end
69+
expect(auth.authorized_scope?(['another'])).to be false
70+
end
71+
end
4472
end

spec/specs/rapid/dsls/api_spec.rb

+10
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,14 @@
6868
expect(api.route_set.find(:get, 'virtual_machines').first).to be_a Rapid::Route
6969
end
7070
end
71+
72+
context '#scopes' do
73+
it 'allows for scopes to be defined' do
74+
dsl.scopes do
75+
add 'widgets', 'Full access to widgets'
76+
add 'widgets:read', 'Read-only access to widgets'
77+
end
78+
expect(api.scopes).to eq({ 'widgets' => { description: 'Full access to widgets' }, 'widgets:read' => { description: 'Read-only access to widgets' } })
79+
end
80+
end
7181
end

spec/specs/rapid/dsls/authenticator_spec.rb

+12
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,16 @@
5454
expect(authenticator.action.call).to eq 10
5555
end
5656
end
57+
58+
context '#scope_validator' do
59+
it 'allows a block to be defined' do
60+
dsl.scope_validator { 1234 }
61+
expect(authenticator.scope_validator.call).to eq 1234
62+
end
63+
64+
it 'adds the scope not granted potential error' do
65+
dsl.scope_validator { 1234 }
66+
expect(authenticator.potential_errors).to include Rapid::ScopeNotGrantedError
67+
end
68+
end
5769
end

spec/specs/rapid/dsls/endpoint_spec.rb

+20
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,24 @@
124124
end
125125
end
126126
end
127+
128+
context '#scope' do
129+
it 'should add a scope' do
130+
dsl.scope 'example:read'
131+
expect(endpoint.scopes).to eq ['example:read']
132+
end
133+
134+
it 'should not add the same scope twice' do
135+
dsl.scope 'example:read'
136+
dsl.scope 'example:read'
137+
expect(endpoint.scopes).to eq ['example:read']
138+
end
139+
end
140+
141+
context '#scopes' do
142+
it 'should add multiple scopes' do
143+
dsl.scopes 'example:read', 'example:write'
144+
expect(endpoint.scopes).to eq ['example:read', 'example:write']
145+
end
146+
end
127147
end

0 commit comments

Comments
 (0)