actn-api 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.env +2 -0
  3. data/.gitignore +17 -0
  4. data/.travis.yml +3 -0
  5. data/Gemfile +4 -0
  6. data/LICENSE.txt +22 -0
  7. data/Procfile +11 -0
  8. data/README.md +130 -0
  9. data/Rakefile +16 -0
  10. data/actn-api.gemspec +33 -0
  11. data/apis/core/backend.rb +103 -0
  12. data/apis/core/frontend.rb +46 -0
  13. data/apis/public/connect.rb +40 -0
  14. data/apis/public/delete.rb +15 -0
  15. data/apis/public/query.rb +26 -0
  16. data/apis/public/upsert.rb +16 -0
  17. data/bin/actn-api +36 -0
  18. data/config/common.rb +16 -0
  19. data/config/core.rb +1 -0
  20. data/config/haproxy.cfg +73 -0
  21. data/config/public.rb +5 -0
  22. data/db/1_api.sql +9 -0
  23. data/db/schemas/client.json +41 -0
  24. data/db/schemas/user.json +23 -0
  25. data/lib/actn/api/client.rb +92 -0
  26. data/lib/actn/api/core.rb +35 -0
  27. data/lib/actn/api/goliath/params.rb +70 -0
  28. data/lib/actn/api/goliath/validator.rb +54 -0
  29. data/lib/actn/api/mw/auth.rb +122 -0
  30. data/lib/actn/api/mw/cors.rb +58 -0
  31. data/lib/actn/api/mw/no_xss.rb +28 -0
  32. data/lib/actn/api/public.rb +89 -0
  33. data/lib/actn/api/user.rb +59 -0
  34. data/lib/actn/api/version.rb +5 -0
  35. data/lib/actn/api.rb +16 -0
  36. data/test/actn/test_backend.rb +81 -0
  37. data/test/actn/test_client.rb +31 -0
  38. data/test/actn/test_connect.rb +42 -0
  39. data/test/actn/test_delete.rb +53 -0
  40. data/test/actn/test_frontend.rb +32 -0
  41. data/test/actn/test_query.rb +60 -0
  42. data/test/actn/test_upsert.rb +77 -0
  43. data/test/actn/test_user.rb +37 -0
  44. data/test/minitest_helper.rb +27 -0
  45. data/test/support/test.html +7 -0
  46. data/views/core/app.erb +1 -0
  47. data/views/public/connect.erb +123 -0
  48. metadata +258 -0
@@ -0,0 +1,41 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-04/schema#",
3
+ "type": "object",
4
+ "properties": {
5
+ "apikey": {
6
+ "description": "Autogenerated API key for basic auth",
7
+ "type": "string"
8
+ },
9
+ "secret_hash": {
10
+ "description": "hashed version of api secret",
11
+ "type": "string"
12
+ },
13
+ "domain": {
14
+ "description": "Allowed domain for CORS calls",
15
+ "type": "string"
16
+ },
17
+ "acl": {
18
+ "description": "Access control list",
19
+ "type": "object",
20
+ "patternProperties": {
21
+ "[allow|disallow]": {
22
+ "type": "array",
23
+ "pattern": "/(\\*|GET|POST|PUT|PATCH|DELETE)(\\:\/.*)?/g"
24
+ }
25
+ },
26
+ "required": ["allow","disallow"]
27
+ },
28
+ "sessions": {
29
+ "description": "Active sessions belongs to client",
30
+ "type": "object",
31
+ "patternProperties": {
32
+ "/.*/": {
33
+ "type": "array",
34
+ "minItems": 2,
35
+ "maxItems": 2
36
+ }
37
+ }
38
+ }
39
+ },
40
+ "required": ["apikey","secret_hash","domain","acl"]
41
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-04/schema#",
3
+ "type": "object",
4
+ "properties": {
5
+ "first_name": {
6
+ "type": "string"
7
+ },
8
+ "last_name": {
9
+ "type": "string"
10
+ },
11
+ "email": {
12
+ "type": "string",
13
+ "format": "email"
14
+ },
15
+ "hash": {
16
+ "type": "string"
17
+ },
18
+ "phone": {
19
+ "type": "string"
20
+ }
21
+ },
22
+ "required": ["first_name","last_name","email"]
23
+ }
@@ -0,0 +1,92 @@
1
+ require 'actn/db/mod'
2
+ require 'actn/core_ext/string'
3
+ require 'bcrypt'
4
+
5
+ module Actn
6
+ module Api
7
+ class Client < DB::Mod
8
+
9
+ self.table = "clients"
10
+ self.schema = "core"
11
+
12
+ BCrypt::Engine.cost = 4
13
+
14
+ DEFAULT_ACL = {allow: ['*'], disallow: []}
15
+ TTL = 360
16
+
17
+ attr_accessor :secret
18
+
19
+ data_attr_accessor :apikey, :secret_hash, :domain, :sessions, :acl
20
+
21
+ validates_presence_of :apikey, :domain, :acl, :secret_hash
22
+
23
+ before_validation :set_defaults
24
+
25
+ def self.find_for_auth domain, apikey
26
+ client = self.find_by(domain: domain, apikey: apikey)
27
+ client
28
+ end
29
+
30
+ def auth_by_secret secret
31
+ self.secret == secret
32
+ end
33
+
34
+ def auth_by_session session_id
35
+ return unless client_session = self.sessions[session_id]
36
+ if BCrypt::Password.new(client_session[0]) == session_id
37
+ if Time.now.to_f - client_session[1] > TTL
38
+ invalidated = self.update(sessions: self.sessions.tap{|s| s.delete session_id })
39
+ return false
40
+ else
41
+ return true
42
+ end
43
+ end
44
+ end
45
+
46
+ def set_session session_id
47
+ self.update( { sessions: {session_id => [BCrypt::Password.create(session_id), Time.now.to_f] }} )
48
+ end
49
+
50
+ def secret
51
+ @hash ||= BCrypt::Password.new(self.secret_hash) if self.secret_hash
52
+ end
53
+
54
+ def credentials
55
+ {'apikey' => self.apikey, 'secret' => @secret}
56
+ end
57
+
58
+ def reset_credentials!
59
+ reset_credentials
60
+ _update
61
+ self
62
+ end
63
+
64
+ def can? resource
65
+ return if self.acl['disallow'].include?("*") || self.acl['disallow'].include?(resource)
66
+ self.acl['allow'].include?("*") || self.acl['allow'].include?(resource)
67
+ end
68
+
69
+ def to_json options = {}
70
+ super(options.merge(methods: [:credentials], exclude: [:sessions, :secret_hash]))
71
+ end
72
+
73
+ private
74
+
75
+ def set_defaults
76
+ self.domain = self.domain.to_domain
77
+ self.sessions ||= {}
78
+ self.acl ||= DEFAULT_ACL
79
+ reset_credentials unless self.persisted?
80
+ end
81
+
82
+ def reset_credentials
83
+ self.apikey = SecureRandom.hex
84
+ @secret = SecureRandom.hex
85
+ @hash = BCrypt::Password.create(@secret)
86
+ self.secret_hash = @hash
87
+ end
88
+
89
+ end
90
+
91
+ end
92
+ end
@@ -0,0 +1,35 @@
1
+ require 'helmet'
2
+ require 'rack/csrf'
3
+ require 'actn/db'
4
+ require 'actn/api/user'
5
+ require 'actn/api/mw/auth'
6
+ require 'actn/api/mw/no_xss'
7
+ require 'actn/api/goliath/validator'
8
+ require 'actn/api/goliath/params'
9
+
10
+ module Actn
11
+ module Api
12
+ class Core < Helmet::API
13
+
14
+ OK = '{"success": true}'
15
+
16
+ def self.inherited base
17
+
18
+ base.init
19
+
20
+ super
21
+
22
+ base.use Goliath::Rack::Params
23
+ base.use Goliath::Rack::Heartbeat
24
+
25
+ base.use Mw::NoXSS
26
+ base.use Rack::Session::Cookie, secret: ENV['SECRET']
27
+ base.use Rack::Csrf, skip_if: proc { |request|
28
+ request.env.key?('HTTP_X_APIKEY') && request.env.key?('HTTP_X_SECRET')
29
+ }
30
+
31
+ end
32
+
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,70 @@
1
+ require 'oj'
2
+ require 'rack/utils'
3
+
4
+ module Goliath
5
+ module Rack
6
+ # URL_ENCODED = %r{^application/x-www-form-urlencoded}
7
+ # JSON_ENCODED = %r{^application/json}
8
+
9
+ # A middle ware to parse params. This will parse both the
10
+ # query string parameters and the body and place them into
11
+ # the _params_ hash of the Goliath::Env for the request.
12
+ #
13
+ # @example
14
+ # use Goliath::Rack::Params
15
+ #
16
+ class Params
17
+ module Parser
18
+ def retrieve_params(env)
19
+ params = env['params'] || {}
20
+ begin
21
+ params.merge!(::Rack::Utils.parse_nested_query(env['QUERY_STRING']))
22
+ if env['rack.input']
23
+ post_params = ::Rack::Utils::Multipart.parse_multipart(env)
24
+ unless post_params
25
+ body = env['rack.input'].read
26
+ env['rack.input'].rewind
27
+
28
+ unless body.empty?
29
+ post_params = case(env['CONTENT_TYPE'])
30
+ when URL_ENCODED then
31
+ ::Rack::Utils.parse_nested_query(body)
32
+ when JSON_ENCODED then
33
+ json = Oj.load(body)
34
+ if json.is_a?(Hash)
35
+ json
36
+ else
37
+ {'_json' => json}
38
+ end
39
+ else
40
+ {}
41
+ end
42
+ else
43
+ post_params = {}
44
+ end
45
+ end
46
+ params = { 'query' => params, 'data' => post_params }
47
+ end
48
+ rescue StandardError => e
49
+ raise Goliath::Validation::BadRequestError, "Invalid parameters: #{e.class.to_s}"
50
+ end
51
+ params
52
+ end
53
+ end
54
+
55
+ include Goliath::Rack::Validator
56
+ include Parser
57
+
58
+ def initialize(app)
59
+ @app = app
60
+ end
61
+
62
+ def call(env)
63
+ Goliath::Rack::Validator.safely(env) do
64
+ env['params'] = retrieve_params(env)
65
+ @app.call(env)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,54 @@
1
+ require 'oj'
2
+
3
+ module Goliath
4
+ module Rack
5
+ module Validator
6
+ module_function
7
+ ERROR = 'error'
8
+
9
+ # @param status_code [Integer] HTTP status code for this error.
10
+ # @param msg [String] message to inject into the response body.
11
+ # @param headers [Hash] Response headers to preserve in an error response;
12
+ # (the Content-Length header, if any, is removed)
13
+ def validation_error(status_code, msg, headers={})
14
+ headers.delete('Content-Length')
15
+ [status_code, headers, Oj.dump({ERROR => msg})]
16
+ end
17
+
18
+ # Execute a block of code safely.
19
+ #
20
+ # If the block raises any exception that derives from
21
+ # Goliath::Validation::Error (see specifically those in
22
+ # goliath/validation/standard_http_errors.rb), it will be turned into the
23
+ # corresponding 4xx response with a corresponding message.
24
+ #
25
+ # If the block raises any other kind of error, we log it and return a
26
+ # less-communicative 500 response.
27
+ #
28
+ # @example
29
+ # # will convert the ForbiddenError exception into a 403 response
30
+ # # and an uncaught error in do_something_risky! into a 500 response
31
+ # safely(env, headers) do
32
+ # raise ForbiddenError unless account_info['valid'] == true
33
+ # do_something_risky!
34
+ # [status, headers, body]
35
+ # end
36
+ #
37
+ #
38
+ # @param env [Goliath::Env] The current request env
39
+ # @param headers [Hash] Response headers to preserve in an error response
40
+ #
41
+ def safely(env, headers={})
42
+ begin
43
+ yield
44
+ rescue Goliath::Validation::Error => e
45
+ validation_error(e.status_code, e.message, headers)
46
+ rescue Exception => e
47
+ env.logger.error(e.message)
48
+ env.logger.error(e.backtrace.join("\n"))
49
+ validation_error(500, e.message, headers)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,122 @@
1
+ require 'goliath/rack'
2
+ require 'goliath/validation'
3
+ require 'bcrypt'
4
+
5
+ ##
6
+ # Bync.rb
7
+ # Bouncer of our midningt api club
8
+
9
+ module Actn
10
+ module Api
11
+ module Mw
12
+ class Auth
13
+
14
+ include Goliath::Rack::BarrierAroundware
15
+ include Goliath::Validation
16
+
17
+ class MissingApikeyError < BadRequestError ; end
18
+ class InvalidCredentialsError < UnauthorizedError ; end
19
+
20
+ attr_accessor :client, :opts
21
+
22
+ def initialize(env, opts = {})
23
+ self.opts = opts
24
+ super(env)
25
+ end
26
+
27
+
28
+ def pre_process
29
+
30
+ unless excluded?
31
+
32
+ validate_apikey!
33
+
34
+ # On non-GET non-HEAD requests, we have to check auth now.
35
+ unless lazy_authorization?
36
+ perform # yield execution until user_info has arrived
37
+ authorize_client!
38
+ end
39
+
40
+ end
41
+
42
+ return Goliath::Connection::AsyncResponse
43
+ end
44
+
45
+ def post_process
46
+
47
+ unless excluded?
48
+
49
+ # We have to check auth now, we skipped it before
50
+ if lazy_authorization?
51
+ validate_client!
52
+ end
53
+
54
+ end
55
+
56
+ [status, headers, body]
57
+ end
58
+
59
+ def lazy_authorization?
60
+ (env['REQUEST_METHOD'] == 'GET') || (env['REQUEST_METHOD'] == 'HEAD')
61
+ end
62
+
63
+ def validate_apikey!
64
+ return true if with_session? && current_user_uuid
65
+ raise MissingApikeyError.new("Missing Api Key") if apikey.to_s.empty?
66
+ end
67
+
68
+ def validate_client!
69
+ return true if with_session? && current_user_uuid
70
+ raise Goliath::Validation::UnauthorizedError unless client_valid?
71
+ end
72
+
73
+ def authorize_client!
74
+ return true if with_session? && current_user_uuid
75
+ unless client_valid? && client_authorized?
76
+ raise InvalidCredentialsError.new("Invalid Credentials")
77
+ end
78
+ env['rack.session'][:user_uuid] = self.client.uuid
79
+ end
80
+
81
+ def apikey
82
+ env['HTTP_X_APIKEY']
83
+ end
84
+
85
+ def secret
86
+ env['HTTP_X_SECRET']
87
+ end
88
+
89
+ def client_valid?
90
+ self.client = Client.find_for_auth(host, apikey)
91
+ end
92
+
93
+ def client_authorized?
94
+ return unless self.client
95
+ (
96
+ self.secret.nil? ?
97
+ self.client.auth_by_session(env['rack.session'].id) :
98
+ self.client.auth_by_secret(self.secret)
99
+ ) && self.client.can?("#{env['REQUEST_METHOD']}:#{env['REQUEST_PATH']}")
100
+ end
101
+
102
+ def host
103
+ (env['HTTP_ORIGIN'] || env['HTTP_HOST']).to_domain
104
+ end
105
+
106
+ def excluded?
107
+ opts[:exclude].nil? ? false : (env['REQUEST_PATH'] =~ opts[:exclude])
108
+ end
109
+
110
+ def with_session?
111
+ opts[:with_session]
112
+ end
113
+
114
+ def current_user_uuid
115
+ env['rack.session'][:user_uuid]
116
+ end
117
+
118
+
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,58 @@
1
+ require 'goliath/rack'
2
+ require 'rack/csrf'
3
+
4
+ ##
5
+ # Cors.rb
6
+ # Cross domain middle ware
7
+
8
+
9
+ module Actn
10
+ module Api
11
+ module Mw
12
+ class Cors
13
+
14
+ include Goliath::Rack::AsyncMiddleware
15
+
16
+ def initialize(app)
17
+ @app = app
18
+ end
19
+
20
+ def call(env)
21
+ if env['REQUEST_METHOD'] == 'OPTIONS'
22
+ [200, cors_headers(env,false), []]
23
+ else
24
+ super(env)
25
+ end
26
+ end
27
+
28
+ def post_process(env, status, headers, body)
29
+ headers = cors_headers(env).merge(headers)
30
+ [status, headers, body]
31
+ end
32
+
33
+ private
34
+
35
+ attr_accessor :options
36
+
37
+ def cors_headers env, csrf = true
38
+ headers = {}
39
+ headers['Access-Control-Allow-Credentials'] = 'true'
40
+ headers['Access-Control-Allow-Origin'] = env['HTTP_ORIGIN']
41
+ headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'
42
+ headers['Access-Control-Allow-Headers'] = '*, X-Requested-With, X-Prototype-Version, X-CSRF-Token, Authorization, Origin, Accept, Content-Type, Referer'
43
+ headers['Access-Control-Expose-Headers'] = 'X_CSRF_TOKEN, X_APIKEY'
44
+ headers['Access-Control-Max-Age'] = "#{Client::TTL}"
45
+ headers['P3P'] = 'CP="IDC DSP COR CURa ADMa OUR IND PHY ONL COM STA"'
46
+
47
+ headers['X_CSRF_TOKEN'] = Rack::Csrf.token(env) if csrf
48
+
49
+ client_headers_to_approve = env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'].to_s.gsub(/[^\w\-\,]+/,'')
50
+ headers['Access-Control-Allow-Headers'] += ",#{client_headers_to_approve}" if not client_headers_to_approve.empty?
51
+
52
+ headers
53
+ end
54
+
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,28 @@
1
+ require 'goliath/rack'
2
+
3
+ ##
4
+ # Sec.rb
5
+ # Security comes first
6
+
7
+ module Actn
8
+ module Api
9
+ module Mw
10
+ class NoXSS
11
+
12
+ include Goliath::Rack::AsyncMiddleware
13
+
14
+ HEADERS = {
15
+ 'X-Frame-Options' => 'SAMEORIGIN',
16
+ 'X-XSS-Protection' => '1; mode=block',
17
+ 'X-Content-Type-Options' => 'nosniff'
18
+ }
19
+
20
+ def post_process(env, status, headers, body)
21
+ headers.update HEADERS
22
+ [status, headers, body]
23
+ end
24
+
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,89 @@
1
+ require 'goliath'
2
+ require 'actn/db/set'
3
+ require 'actn/api/client'
4
+ require 'actn/api/mw/auth'
5
+ require 'actn/api/mw/cors'
6
+ require 'actn/api/mw/no_xss'
7
+ require 'actn/api/goliath/validator'
8
+ require 'actn/api/goliath/params'
9
+
10
+ module Actn
11
+ module Api
12
+ class Public < Goliath::API
13
+
14
+ CT_JS = { 'Content-Type' => 'application/javascript' }
15
+ CT_JSON = {'Content-Type' => 'application/json'}
16
+
17
+
18
+ def self.inherited base
19
+
20
+ super
21
+
22
+ base.use Goliath::Rack::Params
23
+ base.use Goliath::Rack::Heartbeat
24
+
25
+ base.use Mw::NoXSS
26
+ base.use Rack::Session::Cookie, secret: ENV['SECRET']
27
+ base.use Rack::Csrf, skip: ['OPTIONS:/.*'], skip_if: proc { |r| ENV['RACK_ENV'] == "test" }
28
+ base.use Mw::Cors
29
+ base.use Goliath::Rack::BarrierAroundwareFactory, Mw::Auth, exclude: /^\/connect$/
30
+
31
+ super
32
+ end
33
+
34
+
35
+ def process table, path
36
+ raise NotImplementedError
37
+ end
38
+
39
+
40
+
41
+ def response env
42
+
43
+ path = env['REQUEST_PATH'] || "/"
44
+
45
+ unless table = path[1..-1].split("/").first
46
+ raise Goliath::Validation::Error.new(400, "model identifier missing")
47
+ end
48
+
49
+ begin
50
+ json = process(table,path)
51
+ rescue PG::InternalError => e
52
+ if e.message =~ /does not exist/
53
+ raise Goliath::Validation::NotFoundError.new("resource not found")
54
+ else
55
+ raise e
56
+ end
57
+ end
58
+
59
+ status = json =~ /errors/ ? 406 : 200
60
+
61
+ [status, CT_JSON, json]
62
+
63
+ end
64
+
65
+ private
66
+
67
+ def limit
68
+ query['limit'] || 50
69
+ end
70
+
71
+ def page
72
+ ((query['page'] || 1).to_i - 1)
73
+ end
74
+
75
+ def offset
76
+ limit * page
77
+ end
78
+
79
+ def query
80
+ params['query']
81
+ end
82
+
83
+ def data
84
+ params['data']
85
+ end
86
+
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,59 @@
1
+ require 'actn/db/mod'
2
+ require 'actn/core_ext/string'
3
+ require 'bcrypt'
4
+
5
+ module Actn
6
+
7
+ module Api
8
+
9
+ class User < DB::Mod
10
+
11
+ BCrypt::Engine.cost = 4
12
+
13
+ self.schema = "core"
14
+ self.table = "users"
15
+
16
+ def self.find_for_auth params
17
+ return unless user = self.find_by('email' => params['email'])
18
+ return unless user.password == params['password']
19
+ user.uuid
20
+ end
21
+
22
+ attr_accessor :password, :password_confirmation
23
+
24
+ data_attr_accessor :first_name, :last_name, :email, :hash, :phone
25
+
26
+ validates_presence_of :first_name, :last_name, :email
27
+ validates_presence_of :password, :password_confirmation, unless: 'persisted?'
28
+ validates_confirmation_of :password, unless: 'persisted?'
29
+ validate :validate_unique_email, unless: 'persisted?'
30
+
31
+ before_create :set_password
32
+
33
+ def password
34
+ if self.hash
35
+ @password ||= BCrypt::Password.new(self.hash)
36
+ else
37
+ @password
38
+ end
39
+ end
40
+
41
+ def to_json options = {}
42
+ super(options.merge(:exclude [:hash]))
43
+ end
44
+
45
+ private
46
+
47
+ def set_password
48
+ @password = BCrypt::Password.create(self.password)
49
+ self.hash = @password
50
+ end
51
+
52
+
53
+ def validate_unique_email
54
+ errors.add(:email, "has already been taken") if self.class.find_by('email' => self.email)
55
+ end
56
+
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,5 @@
1
+ module Actn
2
+ module Api
3
+ VERSION = "0.0.1"
4
+ end
5
+ end
data/lib/actn/api.rb ADDED
@@ -0,0 +1,16 @@
1
+ require "actn/paths"
2
+ require "actn/db"
3
+ require "actn/api/version"
4
+
5
+ module Actn
6
+ module Api
7
+ include Paths
8
+
9
+ def self.gem_root
10
+ @@gem_root ||= File.expand_path('../../../', __FILE__)
11
+ end
12
+
13
+ end
14
+ end
15
+
16
+ Actn::DB.paths << Actn::Api.gem_root