Skip to content
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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ CANTALOUPE_API_USERNAME=admin
# API password for the Cantaloupe instance
CANTALOUPE_API_PASSWORD=mySecret

# Clerk config
VITE_CLERK_SIGN_IN_URL=https://staging.performant.studio/sso
VITE_CLERK_PUBLISHABLE_KEY=my_key
CLERK_SECRET_KEY=my_secret_key
VITE_CLERK_SIGN_IN_FORCE_REDIRECT_URL=http://localhost:6780/dashboard

# DB Host name. In docker-compose setup this should be db.
# Otherwise it should be the host of the DB (i.e. localhost, NOT http://localhost)
DATABASE_HOST=db
Expand Down Expand Up @@ -100,6 +106,9 @@ SECRET_KEY_BASE=ac2f3e6030bbd6f234267dc400996a1ec109e50b6317b8de34cc76cd9136dd1e
# Recommended: 5
SIDEKIQ_CONCURRENCY=5

# Vite environment
VITE_ENVIRONMENT=development

# Port to use for the Vite dev server.
VITE_PORT=6780

Expand Down
9 changes: 3 additions & 6 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,6 @@ gem 'resource_api', git: 'https://github.com/performant-software/resource-api.gi
# Use Json Web Token (JWT) for token based authentication
gem 'jwt', '~> 3.1.2'

# JWT authentication
gem 'jwt_auth', git: 'https://github.com/performant-software/jwt-auth.git', tag: 'v0.1.3'

# Use ActiveModel has_secure_password
gem 'bcrypt', '~> 3.1.20'

# Active storage service
gem 'aws-sdk-s3', '~> 1.193'

Expand All @@ -51,6 +45,9 @@ gem 'data_migrate', '~> 11.3'
# Background jobs
gem 'sidekiq', '~> 8.0.5', group: :production

# Clerk
gem 'clerk-sdk-ruby', '>= 7.0'

group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem 'debug', platforms: %i[ mri mingw x64_mingw ]
Expand Down
35 changes: 22 additions & 13 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,12 +1,3 @@
GIT
remote: https://github.com/performant-software/jwt-auth.git
revision: 993b47f395d7349c499a0ffda6ef286871ef89dc
tag: v0.1.3
specs:
jwt_auth (0.1.0)
jwt (~> 3.1.2)
rails (>= 7.1, < 9)

GIT
remote: https://github.com/performant-software/resource-api.git
revision: ab5afbdfedfd4e89309d748b46167652562a22bf
Expand Down Expand Up @@ -119,13 +110,19 @@ GEM
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
bigdecimal (3.2.2)
bootsnap (1.18.4)
msgpack (~> 1.2)
builder (3.3.0)
concurrent-ruby (1.3.4)
clerk-sdk-ruby (7.0.0)
base64 (>= 0.2.0, < 1.0)
concurrent-ruby (~> 1.3.5)
faraday (>= 2.14.1)
faraday-multipart (~> 1.2.0)
faraday-retry (~> 2.4.0)
jwt (~> 3.0)
concurrent-ruby (1.3.7)
connection_pool (2.5.3)
crass (1.0.6)
csv (3.3.5)
Expand All @@ -142,6 +139,16 @@ GEM
railties (>= 6.1)
drb (2.2.3)
erubi (1.13.1)
faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
globalid (1.2.1)
activesupport (>= 6.1)
httparty (0.23.1)
Expand Down Expand Up @@ -176,6 +183,9 @@ GEM
msgpack (1.7.5)
multi_xml (0.7.2)
bigdecimal (~> 3.1)
multipart-post (2.4.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.5.9)
date
net-protocol
Expand Down Expand Up @@ -278,14 +288,13 @@ PLATFORMS

DEPENDENCIES
aws-sdk-s3 (~> 1.193)
bcrypt (~> 3.1.20)
bootsnap
clerk-sdk-ruby (>= 7.0)
data_migrate (~> 11.3)
debug
dotenv-rails
httparty (~> 0.23.1)
jwt (~> 3.1.2)
jwt_auth!
mini_magick (~> 5.3.0)
pg (~> 1.5.9)
puma (~> 6.6.0)
Expand Down
44 changes: 24 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,34 @@
# rails-react-template
# FairImage

This README would normally document whatever steps are necessary to get the
application up and running.
## Setup

## Requirements
- Ruby
- NodeJS
- Yarn
- Heroku CLI (optional)
1. Run `bundle install` to install the Rails dependencies.
2. `cd` into `client` and run `yarn` to install the client dependencies.

Things you may want to cover:
To start FairImage, run `bundle exec rake start`.

* Ruby version
## Local Development with Clerk

* System dependencies
To run FairImage locally, you'll need to set up Clerk. Follow these steps:

* Configuration
1. Create a Clerk account at [https://clerk.com](https://clerk.com).
2. Create a new application in Clerk.
3. Make sure organizations are enabled
4. Obtain your Clerk API keys under Instance -> Configure -> API Keys.
5. Plug them into your `.env` file as `VITE_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY`.
6. Obtain your Clerk sign in URL under Configure -> Developers -> Paths -> Component paths -> `<SignIn />`
7. Plug it into your `.env` file as `VITE_CLERK_SIGN_IN_URL`.

* Database creation
You won't be able to sign in until the `sso_id` value is set on the FairImage user record you want to use.

* Database initialization
Assuming you've got your FairImage database set up with users and orgs (either by `heroku pg:pull`ing from staging or creating some records yourself in the console), take these steps:

* How to run the test suite
For each user you want to work with (you'll probably want one admin and one non-admin):
1. Create a corresponding account in the Users tab in Clerk.
2. Copy the User ID value from Clerk and plug it into the FairImage user's `sso_id` field.
3. If you want the account to be an admin, include `is_global_admin: true` in the private metadata.

* Services (job queues, cache servers, search engines, etc.)

* Deployment instructions

* ...
For each organization:
1. Create a corresponding organization in the Organizations tab in Clerk.
2. Copy the Organization ID value from Clerk and plug it into the FairImage organization's `sso_id` field.
3. Add whichever users you want under the Members tab on the organization's Clerk page. (Note: roles are irrelevant to FairImage, but you may want to set them to reasonable values if you're planning to use this Clerk org for other apps.)
58 changes: 54 additions & 4 deletions app/controllers/api/base_controller.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,61 @@
require 'clerk'

class Api::BaseController < Api::ResourceController
# Includes
include JwtAuth::Authenticateable
protected

# Actions
before_action :authenticate_request

protected
attr_reader :current_user

def authenticate_request
token = request.cookies["__session"]
return deny_access unless token.present?

clerk_session = clerk_client.verify_token(token)
clerk_id = clerk_session["sub"]

@current_user = User.find_by(sso_id: clerk_id)

# If the user exists in Clerk but not in FairImage,
# create a local account for them automatically.
if @current_user.nil?
clerk_user = get_clerk_data(clerk_id)
@current_user = create_user_from_clerk(clerk_user)
end

return deny_access unless @current_user

@current_user
end

def clerk_client
@clerk_client ||= Clerk::SDK.new(secret_key: ENV.fetch("CLERK_SECRET_KEY"))
end

def create_user_from_clerk(clerk_user)
user = User.new(
sso_id: clerk_user.id,
email: clerk_user.email_addresses.first.email_address,
name: [clerk_user.first_name, clerk_user.last_name].join(" "),
role: 'member',
)

user.save!

user
end

def get_clerk_data(clerk_id)
clerk_client.users.get(user_id: clerk_id).user
end

def get_clerk_organization_memberships(clerk_id)
clerk_client.users.get_organization_memberships(user_id: clerk_id, limit: 500).organization_memberships.data
end

def get_clerk_role(clerk_user)
clerk_user.public_metadata["role"]
end

def check_authorization(organization_id)
deny_access unless current_user.has_access?(organization_id)
Expand Down
40 changes: 39 additions & 1 deletion app/controllers/api/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,27 @@ class Api::UsersController < Api::BaseController
search_attributes :name, :email

# Preloads
preloads User.attachment_preloads
preloads user_organizations: :organization, only: :show

# Actions
before_action :validate_new_user, unless: -> { current_user.admin? }, only: :create
before_action :validate_user, unless: -> { current_user.admin? }, only: [:update, :destroy]

def me
return unless current_user

puts "Attempting to authenticate user #{current_user.id}"

clerk_user = get_clerk_data(current_user.sso_id)

update_user_from_sso(current_user, clerk_user)
sync_organizations_from_sso(current_user)

serializer = UsersSerializer.new

render json: serializer.render_show(current_user), status: :ok
end

protected

def base_query
Expand All @@ -26,6 +40,30 @@ def base_query

private

def update_user_from_sso(local_user, sso_user)
local_user.assign_attributes(
admin: sso_user.private_metadata['is_global_admin'] == true,
avatar_url: sso_user.profile_image_url
)

local_user.save!
end

def sync_organizations_from_sso(user)
memberships = get_clerk_organization_memberships(user.sso_id)
clerk_organization_ids = memberships.map { |membership| membership.organization.id }

local_organization_ids = Organization.where(sso_id: clerk_organization_ids).pluck(:id)
current_organization_ids = user.user_organizations.pluck(:organization_id)

(local_organization_ids - current_organization_ids).each do |organization_id|
user.user_organizations.create!(organization_id: organization_id)
end

removed_organization_ids = current_organization_ids - local_organization_ids
user.user_organizations.where(organization_id: removed_organization_ids).destroy_all if removed_organization_ids.any?
end

def validate_new_user
organization_ids = params[:user][:user_organizations].map{ |uo| uo[:organization_id] }
check_authorization organization_ids
Expand Down
35 changes: 35 additions & 0 deletions app/middleware/sidekiq_web_authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
require 'clerk'

# Restricts the Sidekiq Web UI to admins authenticated via Clerk.
class SidekiqWebAuthentication
def initialize(app)
@app = app
end

def call(env)
token = Rack::Request.new(env).cookies['__session']
return deny_access unless token.present?

clerk_id = begin
clerk_client.verify_token(token)['sub']
rescue JWT::DecodeError, Clerk::Error
nil
end
return deny_access unless clerk_id

user = User.find_by(sso_id: clerk_id)
return deny_access unless user&.admin?

@app.call(env)
end

private

def clerk_client
@clerk_client ||= Clerk::SDK.new(secret_key: ENV.fetch('CLERK_SECRET_KEY'))
end

def deny_access
[403, { 'Content-Type' => 'text/plain' }, ['Forbidden']]
end
end
5 changes: 1 addition & 4 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ class Organization < ApplicationRecord
# Relationships
has_many :user_organizations, dependent: :destroy

# Nested attributes
accepts_nested_attributes_for :user_organizations, allow_destroy: true

# Resourceable attributes
allow_params :name, :location, user_organizations_attributes: [:id, :user_id, :_destroy]
allow_params :location
end
18 changes: 1 addition & 17 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,9 @@
class User < ApplicationRecord
# Includes
include Attachable

# JWT
has_secure_password

# Relationships
has_many :user_organizations, dependent: :destroy

# Nested attributes
accepts_nested_attributes_for :user_organizations, allow_destroy: true

# Resourceable parameters
allow_params :name, :email, :admin, :api_key, :avatar, :password, :password_confirmation,
user_organizations_attributes: [:id, :organization_id, :_destroy]

# ActiveStorage
has_one_attached :avatar

# Validations
validates :user_organizations, presence: true, unless: :admin?
allow_params :api_key

def has_access?(organization_ids)
if organization_ids.is_a?(Integer)
Expand Down
4 changes: 2 additions & 2 deletions app/serializers/users_serializer.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
class UsersSerializer < BaseSerializer
index_attributes :id, :name, :email, :admin, :avatar_thumbnail_url
show_attributes :id, :name, :email, :admin, :api_key, :avatar_url, :avatar_preview_url, user_organizations: [:id, :organization_id, organization: OrganizationsSerializer]
index_attributes :id, :name, :email, :admin, :avatar_url
show_attributes :id, :name, :email, :admin, :api_key, :avatar_url, user_organizations: [:id, :organization_id, organization: OrganizationsSerializer]
end
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
},
"dependencies": {
"@bunchtogether/vite-plugin-flow": "^1.0.2",
"@clerk/react": "^6.12.2",
"@performant-software/semantic-components": "^3.1.0",
"@performant-software/shared-components": "^3.1.0",
"@performant-software/user-defined-fields": "^3.1.0",
Expand Down
Loading