From a65792ee49a1f00ac2c67d757146f0089a9469d6 Mon Sep 17 00:00:00 2001 From: Konstantin Haase Date: Tue, 13 Jan 2015 15:22:20 +0100 Subject: [PATCH] start working on API v3 --- Gemfile.lock | 6 +- lib/travis/api/app.rb | 13 +++- lib/travis/api/app/endpoint/home.rb | 2 +- lib/travis/api/v3.rb | 13 ++++ lib/travis/api/v3/access_control.rb | 15 +++++ lib/travis/api/v3/access_control/anonymous.rb | 12 ++++ .../api/v3/access_control/application.rb | 25 ++++++++ lib/travis/api/v3/access_control/generic.rb | 48 ++++++++++++++ .../api/v3/access_control/legacy_token.rb | 27 ++++++++ lib/travis/api/v3/access_control/scoped.rb | 19 ++++++ lib/travis/api/v3/access_control/signature.rb | 55 ++++++++++++++++ lib/travis/api/v3/access_control/user.rb | 24 +++++++ lib/travis/api/v3/opt_in.rb | 64 +++++++++++++++++++ lib/travis/api/v3/result.rb | 9 +++ lib/travis/api/v3/router.rb | 20 ++++++ lib/travis/api/v3/routes.rb | 10 +++ lib/travis/api/v3/routes/dsl.rb | 5 ++ lib/travis/api/v3/service.rb | 6 ++ lib/travis/api/v3/services.rb | 4 ++ lib/travis/api/v3/services/find_repository.rb | 22 +++++++ 20 files changed, 392 insertions(+), 7 deletions(-) create mode 100644 lib/travis/api/v3.rb create mode 100644 lib/travis/api/v3/access_control.rb create mode 100644 lib/travis/api/v3/access_control/anonymous.rb create mode 100644 lib/travis/api/v3/access_control/application.rb create mode 100644 lib/travis/api/v3/access_control/generic.rb create mode 100644 lib/travis/api/v3/access_control/legacy_token.rb create mode 100644 lib/travis/api/v3/access_control/scoped.rb create mode 100644 lib/travis/api/v3/access_control/signature.rb create mode 100644 lib/travis/api/v3/access_control/user.rb create mode 100644 lib/travis/api/v3/opt_in.rb create mode 100644 lib/travis/api/v3/result.rb create mode 100644 lib/travis/api/v3/router.rb create mode 100644 lib/travis/api/v3/routes.rb create mode 100644 lib/travis/api/v3/routes/dsl.rb create mode 100644 lib/travis/api/v3/service.rb create mode 100644 lib/travis/api/v3/services.rb create mode 100644 lib/travis/api/v3/services/find_repository.rb diff --git a/Gemfile.lock b/Gemfile.lock index 72c9eb88..df988b45 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -68,15 +68,15 @@ GIT GIT remote: git://github.com/travis-ci/travis-support.git - revision: 40365216662f639d36fc3a0463c4e189ee1563dd + revision: 4fdd220ed7b06a12951e5d74a763c05a80eb0d20 specs: travis-support (0.0.1) GIT remote: git://github.com/travis-ci/travis-yaml.git - revision: 08ec0c4d0cf3366cd971d4acd9aadbc0db68f85d + revision: f3aa306016a08b66a487f966eb8aa3a60ee9b319 specs: - travis-yaml (0.1.0) + travis-yaml (0.2.0) PATH remote: . diff --git a/lib/travis/api/app.rb b/lib/travis/api/app.rb index 1934ede2..dc2d6105 100644 --- a/lib/travis/api/app.rb +++ b/lib/travis/api/app.rb @@ -20,6 +20,7 @@ require 'metriks/librato_metrics_reporter' require 'travis/support/log_subscriber/active_record_metrics' require 'fileutils' require 'travis/api/v2/http' +require 'travis/api/v3' # Rack class implementing the HTTP API. # Instances respond to #call. @@ -110,11 +111,17 @@ module Travis::Api env['travis.global_prefix'] = env['SCRIPT_NAME'] end - use Travis::Api::App::Middleware::ScopeCheck + use Travis::Api::App::Middleware::Logging - use Travis::Api::App::Middleware::Metriks - use Travis::Api::App::Middleware::Rewrite + use Travis::Api::App::Middleware::ScopeCheck use Travis::Api::App::Middleware::UserAgentTracker + use Travis::Api::App::Middleware::Metriks + + # if this is a v3 API request, ignore everything after + use Travis::API::V3::OptIn + + # rewrite should come after V3 hook + use Travis::Api::App::Middleware::Rewrite SettingsEndpoint.subclass :env_vars if Travis.config.endpoints.ssh_key diff --git a/lib/travis/api/app/endpoint/home.rb b/lib/travis/api/app/endpoint/home.rb index bb3b3eb7..545aea41 100644 --- a/lib/travis/api/app/endpoint/home.rb +++ b/lib/travis/api/app/endpoint/home.rb @@ -17,7 +17,7 @@ class Travis::Api::App # Landing point. Redirects web browsers to [API documentation](#/docs/). get '/' do pass if settings.disable_root_endpoint? - redirect to('/docs/') if request.preferred_type('application/json', 'text/html') == 'text/html' + redirect to('/docs/') if request.preferred_type('application/json', 'application/json-home', 'text/html') == 'text/html' { 'hello' => 'world' } end diff --git a/lib/travis/api/v3.rb b/lib/travis/api/v3.rb new file mode 100644 index 00000000..3cc17fa0 --- /dev/null +++ b/lib/travis/api/v3.rb @@ -0,0 +1,13 @@ +module Travis + module API + module V3 + def load_dir(dir, recursive: true) + Dir.glob("#{dir}/*.rb").each { |f| require f[%r[(?<=lib/).+(?=\.rb$)]] } + Dir.glob("#{dir}/*").each { |dir| load_dir(dir) } if recursive + end + + extend self + load_dir("#{__dir__}/v3") + end + end +end diff --git a/lib/travis/api/v3/access_control.rb b/lib/travis/api/v3/access_control.rb new file mode 100644 index 00000000..37500817 --- /dev/null +++ b/lib/travis/api/v3/access_control.rb @@ -0,0 +1,15 @@ +module Travis::API::V3 + module AccessControl + REGISTER = {} + + def self.new(env) + type, payload = env['HTTP_AUTHORIZATION'.freeze].to_s.split(" ", 2) + payload &&= payload.unpack(?m.freeze).first if type == 'basic'.freeze + payload &&= type == 'token'.freeze ? payload.gsub(/^"(.+)"$/, '\1'.freeze) : payload.split(?:.freeze) + modes = REGISTER.fetch(type, []) + access_control = modes.inject(nil) { |current, mode| current || mode.for_request(type, payload, env) } + raise WrongCredentials unless access_control + access_control + end + end +end diff --git a/lib/travis/api/v3/access_control/anonymous.rb b/lib/travis/api/v3/access_control/anonymous.rb new file mode 100644 index 00000000..98175a80 --- /dev/null +++ b/lib/travis/api/v3/access_control/anonymous.rb @@ -0,0 +1,12 @@ +require 'travis/api/v3/access_control/generic' + +module Travis::API::V3 + class AccessControl::Anonymous < AccessControl::Generic + # use when Authorization header is not set + auth_type(nil) + + def self.for_request(*) + new + end + end +end diff --git a/lib/travis/api/v3/access_control/application.rb b/lib/travis/api/v3/access_control/application.rb new file mode 100644 index 00000000..47ff63c8 --- /dev/null +++ b/lib/travis/api/v3/access_control/application.rb @@ -0,0 +1,25 @@ +require 'travis/api/v3/access_control/generic' + +module Travis::API::V3 + class AccessControl::Application < AccessControl::Generic + attr_reader :application_name, :config, :user + + def initialize(application_name, user: nil) + @application_name = application_name + @config = Travis.config.applications[application_name] + @user = user + raise ArgumentError, 'unknown application %p' % application_name unless config + raise ArgumentError, 'cannot use %p without a user' % application_name if config.requires_user and not user + end + + protected + + def logged_in? + !!user + end + + def full_access? + config.full_access + end + end +end diff --git a/lib/travis/api/v3/access_control/generic.rb b/lib/travis/api/v3/access_control/generic.rb new file mode 100644 index 00000000..db159ebc --- /dev/null +++ b/lib/travis/api/v3/access_control/generic.rb @@ -0,0 +1,48 @@ +module Travis::API::V3 + class AccessControl::Generic + def self.for_request(type, payload, env) + end + + def self.auth_type(*list) + list.each { |e| (AccessControl::REGISTER[e] ||= []) << self } + end + + def visible?(object) + full_access? or dispatch(object) + end + + protected + + def repository_visible?(repository) + return true if unrestricted_api? and not repository.private? + private_repository_visible?(repository) + end + + def private_repository_visible?(repository) + false + end + + def full_access? + false + end + + def logged_in? + false + end + + def public_api? + Travis.config.public_api + end + + def unrestricted_api? + full_access? or logged_in? or public_api? + end + + private + + def dispatch(object, method = caller_locations.first.base_label) + method = object.class.name.underscore + ?_.freeze + method + public_send(method) if respond_to?(method) + end + end +end diff --git a/lib/travis/api/v3/access_control/legacy_token.rb b/lib/travis/api/v3/access_control/legacy_token.rb new file mode 100644 index 00000000..6e17408e --- /dev/null +++ b/lib/travis/api/v3/access_control/legacy_token.rb @@ -0,0 +1,27 @@ +require 'travis/api/app/access_token' +require 'travis/api/v3/access_control/user' + +module Travis::API::V3 + # Using v2 API tokens to access v3 API. + # Allows us to later introduce a new way of storing tokens with more capabilities without API users having to know. + class AccessControl::LegacyToken < AccessControl::User + auth_type('token', 'basic') + + def self.for_request(type, payload, env) + payload = paylad.first if payload.is_a? Array + token = Travis::Api::App::AccessToken.find_by_token(payload) + new(token) if token + end + + def initialize(token) + @token = token + super(token.user) + end + + protected + + def permission?(action, id) + super if token.scopes.include? :private + end + end +end diff --git a/lib/travis/api/v3/access_control/scoped.rb b/lib/travis/api/v3/access_control/scoped.rb new file mode 100644 index 00000000..33160381 --- /dev/null +++ b/lib/travis/api/v3/access_control/scoped.rb @@ -0,0 +1,19 @@ +require 'travis/api/v3/access_control/generic' + +module Travis::API::V3 + class AccessControl::Scoped < AccessControl::Generic + attr_accessor :unscoped, :owner_name, :name + + def initialize(scope, unscoped) + @owner_name, @name = scope.split(?/.freeze, 2) + @unscoped = unscoped + end + + protected + + def private_repository_visible?(repository) + return false if name and repository.name != name + unscoped.private_repository_visible?(repository) if repository.owner_name == owner_name + end + end +end diff --git a/lib/travis/api/v3/access_control/signature.rb b/lib/travis/api/v3/access_control/signature.rb new file mode 100644 index 00000000..5ec284cf --- /dev/null +++ b/lib/travis/api/v3/access_control/signature.rb @@ -0,0 +1,55 @@ +require 'travis/api/v3/access_control/generic' +require 'travis/api/app/access_token' +require 'digest/sha1' +require 'openssl' + +module Travis::API::V3 + # Support signed requests to not expose the secret to an untrusted environment. + class AccessControl::Signature < AccessControl::Generic + auth_type('signature') + + def self.for_request(type, payload, env) + *args, signature = payload + options = Hash[args.map { |a| a.split(?=.freeze, 2) }] + challenge = "" + + if github_id = options[?u.freeze] + return unless user = ::User.find_by_github_id(github_id) + end + + if application = options[?a.freeze] + return unless Travis.config.application and app_config = Travis.config.applications[application] + end + + challenge << env['REQUEST_METHOD'.freeze] << "\n".freeze if options[?c.freeeze].include?(?m.freeze) + challenge << env['SCRIPT_NAME'.freeze] << env['PATH_INFO'.freeze] << "\n" if options[?c.freeeze].include?(?p.freeze) + challenge << app_config[:secret] if app_config and user + challenge << args.join(?:.freeze) + + if app_config + control = AccessControl::Application.new(application, user: user) + secrets = user ? secrets_for(user) : [app_config[:secret]] + else + control = AccessControl::User.new(user) + secrets = secrets_for(user) + end + + if scope = options[?s.freeze] + control &&= Scoped.new(scope, control) + end + + control if secrets.any? { |secret| signed(challenge, secret) == signature } + end + + def self.secrets_for(user) + [ + Travis::Api::App::AccessToken.new(user: user, app_id: 1), # generated from github token + Travis::Api::App::AccessToken.new(user: user, app_id: 0) # used by web + ] + end + + def signed(challenge, secret) + OpenSSL::HMAC.hexdigest('sha256'.freeze, secret, challenge) + end + end +end diff --git a/lib/travis/api/v3/access_control/user.rb b/lib/travis/api/v3/access_control/user.rb new file mode 100644 index 00000000..a735b81f --- /dev/null +++ b/lib/travis/api/v3/access_control/user.rb @@ -0,0 +1,24 @@ +require 'travis/api/v3/access_control/generic' + +module Travis::API::V3 + class AccessControl::User < AccessControl::Generic + attr_reader :user, :permissions + + def initialize(user) + @user = user + @permissions = user.permissions.where(user_id: user.id) + super() + end + + protected + + def private_repository_visible?(repository) + permissions?(:pull, repository) + end + + def permission?(type, id) + id = id.id if id.is_a? ::Repository + permissions.where(type => trye, :repository_id => id).any? + end + end +end diff --git a/lib/travis/api/v3/opt_in.rb b/lib/travis/api/v3/opt_in.rb new file mode 100644 index 00000000..c95bcd0c --- /dev/null +++ b/lib/travis/api/v3/opt_in.rb @@ -0,0 +1,64 @@ +module Travis::API::V3 + class OptIn + attr_reader :legacy_stack, :prefix, :router, :accept, :version_header + + def initialize(legacy_stack, prefix: '/v3/', router: Router.new, accept: 'application/vnd.travis-ci.3+', version_header: 'Travis-API-Version') + @legacy_stack = legacy_stack + @prefix = prefix + @router = router + @accept = accept + @version_header = "HTTP_#{version_header.upcase.gsub(/\W/, '_')}" + end + + def call(env) + return redirect(env) if redirect?(env) + + if matched = matching_env(env) + result = @router.call(matched) + result, missing = nil, result if cascade?(*result) + end + + result = result || legacy_stack.call(env) + pick(result, missing) + end + + def pick(result, missing) + return result if missing.nil? + return result if result[0] != 404 + missing + end + + def redirect?(env) + env['PATH_INFO'.freeze] + ?/.freeze == prefix + end + + def redirect(env) + [307, {'Location'.freeze => env['SCRIPT_NAME'.freeze] + prefix, 'Conent-Type'.freeze => 'text/plain'.freeze}, []] + end + + def cascade?(status, headers, body) + status % 100 == 4 and headers['X-Cascade'.freeze] == 'pass'.freeze + end + + def matching_env(env) + for_v3 = from_prefix(env) || from_accept(env) || from_version_header(env) + for_v3 == true ? env : for_v3 + end + + def from_prefix(env) + return unless prefix and env['PATH_INFO'.freeze].start_with?(prefix) + env.merge({ + 'SCRIPT_NAME'.freeze => env['SCRIPT_NAME'.freeze] + prefix, + 'PATH_INFO'.freeze => env['PATH_INFO'.freeze][prefix.size..-1] + }) + end + + def from_accept(env) + env['HTTP_ACCEPT'.freeze].include?(accept) if accept and env.include?('HTTP_ACCEPT'.freeze) + end + + def from_version_header(env) + env[version_header] == '3'.freeze if version_header and env.include?(version_header) + end + end +end diff --git a/lib/travis/api/v3/result.rb b/lib/travis/api/v3/result.rb new file mode 100644 index 00000000..9aff2b05 --- /dev/null +++ b/lib/travis/api/v3/result.rb @@ -0,0 +1,9 @@ +module Travis::API::V3 + class Result + attr_accessor :resource + + def initialize(resource = nil) + @resource = resource + end + end +end diff --git a/lib/travis/api/v3/router.rb b/lib/travis/api/v3/router.rb new file mode 100644 index 00000000..ee243ad5 --- /dev/null +++ b/lib/travis/api/v3/router.rb @@ -0,0 +1,20 @@ +module Travis::API::V3 + class Router + not_found = '{"error":{"message":"not found"}}'.freeze + headers = { 'Content-Type'.freeze => 'application/json'.freeze, 'X-Cascade'.freeze => 'pass'.freeze, 'Content-Length'.freeze => not_found.bytesize } + NOT_FOUND = [ 404, headers, not_found ] + + attr_accessor :routs, :not_found + + def initialize(routes = Routes, not_found: NOT_FOUND) + @routes = routes + @not_found = not_found + end + + def call(env) + access_control = AccessControl.new(env) + p access_control + not_found + end + end +end diff --git a/lib/travis/api/v3/routes.rb b/lib/travis/api/v3/routes.rb new file mode 100644 index 00000000..12b98654 --- /dev/null +++ b/lib/travis/api/v3/routes.rb @@ -0,0 +1,10 @@ +module Travis::API::V3 + module Routes + require 'travis/api/v3/routes/dsl' + extend DSL + + resource :repository do + get :find_repository + end + end +end diff --git a/lib/travis/api/v3/routes/dsl.rb b/lib/travis/api/v3/routes/dsl.rb new file mode 100644 index 00000000..544efbd7 --- /dev/null +++ b/lib/travis/api/v3/routes/dsl.rb @@ -0,0 +1,5 @@ +module Travis::API::V3 + module Routes::DSL + def resource(*) end + end +end diff --git a/lib/travis/api/v3/service.rb b/lib/travis/api/v3/service.rb new file mode 100644 index 00000000..0f0df724 --- /dev/null +++ b/lib/travis/api/v3/service.rb @@ -0,0 +1,6 @@ +module Travis::API::V3 + class Service + def self.params(*) + end + end +end diff --git a/lib/travis/api/v3/services.rb b/lib/travis/api/v3/services.rb new file mode 100644 index 00000000..d83758cf --- /dev/null +++ b/lib/travis/api/v3/services.rb @@ -0,0 +1,4 @@ +module Travis::API::V3 + module Services + end +end diff --git a/lib/travis/api/v3/services/find_repository.rb b/lib/travis/api/v3/services/find_repository.rb new file mode 100644 index 00000000..f749a173 --- /dev/null +++ b/lib/travis/api/v3/services/find_repository.rb @@ -0,0 +1,22 @@ +module Travis::API::V3 + class Services::FindRepository < Service + params :id, :github_id, :slug, optional: true + + def run + raise NotFound unless repository and access_control.visible? repository + Result.new(repository) + end + + def repository + raise EntityMissing if defined?(@repository) and @repository.nil? + @repository ||= find_repository + end + + def find_repository + return ::Repository.find_by_id(id) if id + return ::Repository.find_by_github_id(github_id) if github_id + return ::Repository.by_slug(slug).first if slug + raise WrongParams + end + end +end