Skip to content
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

FFS-2403: Token-based authentication #431

Merged
merged 15 commits into from
Feb 3, 2025
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
4 changes: 4 additions & 0 deletions app/.env
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ MA_PINWHEEL_ENVIRONMENT=sandbox
SANDBOX_PINWHEEL_ENVIRONMENT=sandbox
MAINTENANCE_MODE=false
[email protected]

ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=primary
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=deterministic
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=derivationsalt
allthesignals marked this conversation as resolved.
Show resolved Hide resolved
21 changes: 11 additions & 10 deletions app/app/controllers/api/invitations_controller.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
class Api::InvitationsController < ApplicationController
skip_forgery_protection

def create
if service_account_user.nil?
return render json: { error: "User not found" }, status: :unprocessable_entity
end
before_action :authenticate

@cbv_flow_invitation = CbvInvitationService.new(event_logger).invite(cbv_flow_invitation_params, service_account_user)
def create
@cbv_flow_invitation = CbvInvitationService.new(event_logger).invite(cbv_flow_invitation_params, @current_user, delivery_method: nil)

if @cbv_flow_invitation.errors.any?
return render json: @cbv_flow_invitation.errors, status: :unprocessable_entity
Expand All @@ -21,11 +19,6 @@ def create
}, status: :created
end

# todo: replace with inference via API_KEY
def service_account_user
User.find_by(id: cbv_flow_invitation_params[:user_id])
end

# can these be inferred from the model?
def cbv_flow_invitation_params
params.permit(
Expand All @@ -47,4 +40,12 @@ def cbv_flow_invitation_params
def site_id
cbv_flow_invitation_params[:site_id]
end

private

def authenticate
authenticate_or_request_with_http_token do |token, options|
@current_user = User.find_by_access_token(token)
end
end
end
9 changes: 9 additions & 0 deletions app/app/models/api_access_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class ApiAccessToken < ApplicationRecord
belongs_to :user

encrypts :access_token, deterministic: true

before_create do
self.access_token = SecureRandom.alphanumeric(32)
end
end
8 changes: 8 additions & 0 deletions app/app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,12 @@ class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :rememberable, :trackable, :timeoutable, :omniauthable

has_many :api_access_tokens, dependent: :destroy

def self.find_by_access_token(token)
token_user = ApiAccessToken.find_by(access_token: token, deleted_at: nil)&.user

token_user if token_user && token_user.is_service_account
end
end
5 changes: 5 additions & 0 deletions app/config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,10 @@ class Application < Rails::Application
config.autoload_paths += %W[#{config.root}/app/helpers]
config.autoload_paths += %W[#{config.root}/app/controllers/concerns]
config.sites = SiteConfig.new(Rails.root.join("config", "site-config.yml"))

# See: https://guides.rubyonrails.org/active_record_encryption.html#setup
config.active_record.encryption.primary_key = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"]
config.active_record.encryption.deterministic_key = ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"]
config.active_record.encryption.key_derivation_salt = ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"]
end
end
11 changes: 11 additions & 0 deletions app/db/migrate/20250131150153_create_api_access_tokens.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class CreateApiAccessTokens < ActiveRecord::Migration[7.1]
def change
create_table :api_access_tokens do |t|
t.string :access_token
t.integer :user_id
t.datetime :deleted_at

t.timestamps
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddIsServiceAccountToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :is_service_account, :boolean, default: false
end
end
11 changes: 10 additions & 1 deletion app/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.1].define(version: 2025_01_30_160642) do
ActiveRecord::Schema[7.1].define(version: 2025_01_31_205655) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"

create_table "api_access_tokens", force: :cascade do |t|
t.string "access_token"
t.integer "user_id"
t.datetime "deleted_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end

create_table "cbv_clients", force: :cascade do |t|
t.string "case_number"
t.string "first_name", null: false
Expand Down Expand Up @@ -103,6 +111,7 @@
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "invalidated_session_ids"
t.boolean "is_service_account", default: false
t.index ["email", "site_id"], name: "index_users_on_email_and_site_id", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
Expand Down
24 changes: 14 additions & 10 deletions app/spec/controllers/api/invitations_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@
RSpec.describe Api::InvitationsController do
describe "#create" do
# must be existing user
let(:service_account_user) do
create(:user, email: "[email protected]", site_id: 'ma')
let(:api_access_token) do
user = create(:user, :with_access_token, email: "[email protected]", site_id: 'ma', is_service_account: true)
user.api_access_tokens.first
end

let(:valid_params) do
attributes_for(:cbv_flow_invitation,
site_id: "ma",
beacon_id: "ABC123",
agency_id_number: "7890120",
user_id: service_account_user.id
agency_id_number: "7890120"
)
end

before do
request.headers["Authorization"] = "Bearer #{api_access_token.access_token}"
end
Comment on lines +19 to +21
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wish there was a nicer helper for this kind of thing


it "creates an invitation" do
post :create, params: valid_params
expect(response).to have_http_status(:created)
Expand All @@ -35,16 +39,16 @@
end
end

context "invalid user" do
let(:invalid_user_params) do
valid_params.merge(user_id: 0)
context "unauthorized user" do
before do
request.headers["Authorization"] = nil
end

it "returns unprocessable entity" do
post :create, params: invalid_user_params
post :create, params: valid_params

expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body).keys).to include("error")
expect(response).to have_http_status(:unauthorized)
expect(response.body).to include("HTTP Token: Access denied.")
end
end

Expand Down
3 changes: 3 additions & 0 deletions app/spec/factories/api_access_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FactoryBot.define do
factory :api_access_token
end
8 changes: 8 additions & 0 deletions app/spec/factories/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,13 @@
factory :user do
site_id { "sandbox" }
sequence(:email) { |n| "user#{n}@example.com" }

trait :with_access_token do
after(:build) do |user, evaluator|
user.api_access_tokens = [
create(:api_access_token, user: user)
]
end
end
end
end
19 changes: 18 additions & 1 deletion app/spec/models/user_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
require 'rails_helper'

RSpec.describe User, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
it "finds a user by access token" do
user = create(:user, :with_access_token, is_service_account: true)
token = user.api_access_tokens.first.access_token
found_user = User.find_by_access_token(token)
expect(found_user.id).to eq(user.id)
end

it "rejects an access token if it's marked as deleted" do
user = create(:user, :with_access_token, is_service_account: true)
token = user.api_access_tokens.first.access_token
user.api_access_tokens.first.update(deleted_at: Time.now)
expect(User.find_by_access_token(token)).to be_nil
end

it "works if it cannot find user by access token" do
missing_user = User.find_by_access_token("junk_token")
expect(missing_user).to be_nil
end
end
Binary file modified docs/app/rendered/database-schema.pdf
Binary file not shown.
14 changes: 14 additions & 0 deletions infra/app/app-config/env-config/environment-variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ locals {
manage_method = "manual"
secret_store_name = "/service/${var.app_name}-${var.environment}/maintenance-mode"
},
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY = {
manage_method = "manual"
secret_store_name = "/service/${var.app_name}-${var.environment}/active-record-encryption-primary-key"
},
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY = {
manage_method = "manual"
secret_store_name = "/service/${var.app_name}-${var.environment}/active-record-encryption-deterministic-key"
},
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT = {
manage_method = "manual"
secret_store_name = "/service/${var.app_name}-${var.environment}/active-record-encryption-key-derivation-salt"
},


# Transmission Configuration:
NYC_HRA_EMAIL = {
manage_method = "manual"
Expand Down