Skip to content

Sharepoint integration #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Dec 2, 2024
Merged
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
2 changes: 2 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Metrics/ClassLength:
Max: 130
Exclude:
- 'lib/browse_everything/driver/google_drive.rb'
- 'lib/browse_everything/driver/sharepoint.rb'

Metrics/MethodLength:
Exclude:
Expand Down Expand Up @@ -79,6 +80,7 @@ Style/MixinUsage:
- 'spec/lib/browse_everything/driver/file_system_spec.rb'
- 'spec/lib/browse_everything/driver/google_drive_spec.rb'
- 'spec/lib/browse_everything/driver/s3_spec.rb'
- 'spec/lib/browse_everything/driver/sharepoint_spec.rb'
- 'spec/services/browser_factory_spec.rb'

Style/NumericLiterals:
Expand Down
28 changes: 28 additions & 0 deletions SharePoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Sharepoint Provider

This provider will allow browse-everything to access Sharepoint on behalf of a specific user. It routes through the `/me/joinedTeams` and `/me/drives` Graph API endpoints, so will list Teams that the user belongs to and the user's personal drives at the top level. Within each Team, it will expand to list any child drives or files that the user has permission to access.

https://learn.microsoft.com/en-us/graph/auth-v2-user?tabs=http

Prerequisite:
* App must be registered in the Entra Admin center to receive client_id, client_secret, and tenant_id.
* If using .default endpoint as your scope, you must register API permissions for your application. Minimum permissions:
* Files.Read
* Files.Read.All
* Files.Read.Selected
* offline_access
* openid
* profile
* Team.ReadBasic.All
* User.Read

To use the sharepoint provider add the following to `config/browse_everything_providers.yml`:

```
sharepoint:
client_id: MyAppClientID
client_secret: MyAppClientSecret
tenant_id: MyAzureTenantID
redirect_uri: https://avalon_example.com/browse/connect
scope: offline_access https://graph.microsoft.com/.default
```
7 changes: 7 additions & 0 deletions lib/browse_everything.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module Driver
autoload :Box, 'browse_everything/driver/box'
autoload :GoogleDrive, 'browse_everything/driver/google_drive'
autoload :S3, 'browse_everything/driver/s3'
autoload :Sharepoint, 'browse_everything/driver/sharepoint'

# Access the sorter set for the base driver class
# @return [Proc]
Expand All @@ -39,6 +40,12 @@ module Google
end
end

module Auth
module Sharepoint
autoload :Session, 'browse_everything/auth/sharepoint/session'
end
end

class InitializationError < RuntimeError; end
class ConfigurationError < StandardError; end
class NotImplementedError < StandardError; end
Expand Down
149 changes: 149 additions & 0 deletions lib/browse_everything/auth/sharepoint/session.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# frozen_string_literal: true

require 'oauth2'

# BrowseEverything OAuth2 session for
# Sharepoint provider
module BrowseEverything
module Auth
module Sharepoint
class Session
OAUTH2_URLS = {
site: 'https://login.microsoftonline.com'
}.freeze

def initialize(opts = {})
token_info = opts[:access_token]&.symbolize_keys

if opts[:client_id]
@oauth2_client = OAuth2::Client.new(opts[:client_id],
opts[:client_secret],
{
authorize_url: authorize_url(opts[:tenant_id]),
token_url: token_url(opts[:tenant_id]),
redirect_uri: opts[:redirect_uri],
scope: opts[:scope]
}.merge!(OAUTH2_URLS.dup))
return if token_info.blank?
@access_token = OAuth2::AccessToken.new(@oauth2_client,
token_info[:token],
{
refresh_token: token_info[:refresh_token],
expires_in: token_info[:expires_in]
})
end
end

def authorize_url(tenant_id)
tenant_id + "/oauth2/v2.0/authorize"
end

def token_url(tenant_id)
tenant_id + "/oauth2/v2.0/token"
end

def get_access_token(code)
@access_token = @oauth2_client.auth_code.get_token(code)
end

def refresh_token
@access_token = @access_token.refresh!
end

def build_auth_header
"BoxAuth api_key=#{@api_key}&auth_token=#{@auth_token}"
end

# TODO: Figure out if these HTTP related methods are actually necessary
def get(url, raw = false)
uri = URI.parse(url)
request = Net::HTTP::Get.new(uri.request_uri)
request(uri, request, raw)
end

def delete(url, raw = false)
uri = URI.parse(url)
request = Net::HTTP::Delete.new(uri.request_uri)
request(uri, request, raw)
end

def request(uri, request, raw = false, retries = 0)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
# http.set_debug_output($stdout)

if @access_token
request.add_field('Authorization', "Bearer #{@access_token.token}")
else
request.add_field('Authorization', build_auth_header)
end

request.add_field('As-User', @as_user.to_s) if @as_user

response = http.request(request)

if response.is_a? Net::HTTPNotFound
raise RubyBox::ObjectNotFound
end

# Got unauthorized (401) status, try to refresh the token
if response.code.to_i == 401 && @refresh_token && retries.zero?
refresh_token(@refresh_token)
return request(uri, request, raw, retries + 1)
end

sleep(@backoff) # try not to excessively hammer API.

handle_errors(response, raw)
end

def do_stream(url, opts)
params = {
content_length_proc: opts[:content_length_proc],
progress_proc: opts[:progress_proc]
}

params['Authorization'] = if @access_token
"Bearer #{@access_token.token}"
else
build_auth_header
end

params['As-User'] = @as_user if @as_user

open(url, params)
end

# rubocop: disable Metrics/CyclomaticComplexity
def handle_errors(response, raw)
status = response.code.to_i
body = response.body
begin
parsed_body = JSON.parse(body)
rescue
msg = body.presence || "no data returned"
parsed_body = { "message" => msg }
end

# status is used to determine whether
# we need to refresh the access token.
parsed_body["status"] = status

case status / 100
when 3
# 302 Found. We should return the url
parsed_body["location"] = response["Location"] if status == 302
when 4
raise(RubyBox::ItemNameInUse.new(parsed_body, status, body), parsed_body["message"]) if parsed_body["code"] == "item_name_in_use"
raise(RubyBox::AuthError.new(parsed_body, status, body), parsed_body["message"]) if parsed_body["code"] == "unauthorized" || status == 401
raise(RubyBox::RequestError.new(parsed_body, status, body), parsed_body["message"])
when 5
raise(RubyBox::ServerError.new(parsed_body, status, body), parsed_body["message"])
end
raw ? body : parsed_body
end
# rubocop: enable Metrics/CyclomaticComplexity
end
end
end
end
7 changes: 3 additions & 4 deletions lib/browse_everything/driver/google_drive.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def drive_details(drive)
# @return [Array<BrowseEverything::FileEntry>] file entries for the drives
def list_drives(drive)
page_token = nil
drive.list_drives(:fields=>"nextPageToken,drives(name,id)", :page_size=>100) do |drive_list, error|
drive.list_drives(fields: "nextPageToken,drives(name,id)", page_size: 100) do |drive_list, error|
# Raise an exception if there was an error Google API's
if error.present?
# In order to properly trigger reauthentication, the token must be cleared
Expand All @@ -130,15 +130,14 @@ def list_drives(drive)
@entries += list_drives(drive) if page_token.present?
end


# Retrieve the files for any given resource on Google Drive
# @param path [String] the root or Folder path for which to list contents
# @return [Array<BrowseEverything::FileEntry>] file entries for the path
def contents(path = '')
@entries = []
if path.empty?
@entries << drive_details(Google::Apis::DriveV3::Drive.new(id: "root", name: "My Drive" ))
@entries << drive_details(Google::Apis::DriveV3::Drive.new(id: "shared_drives", name: "Shared drives" )) if drive_service.list_drives.drives.any?
@entries << drive_details(Google::Apis::DriveV3::Drive.new(id: "root", name: "My Drive"))
@entries << drive_details(Google::Apis::DriveV3::Drive.new(id: "shared_drives", name: "Shared drives")) if drive_service.list_drives.drives.any?
elsif path == 'shared_drives'
drive_service.batch do |drive|
list_drives(drive)
Expand Down
Loading