cased-ruby

A Cased client for Ruby applications in your organization to control and monitor the access of information within your organization.

Overview

Installation

Add this line to your application's Gemfile:

gem 'cased-ruby'

And then execute:

$ bundle

Or install it yourself as:

$ gem install cased-ruby

Configuration

All configuration options available in cased-ruby are available to be configured by an environment variable or manually.

Cased.configure do |config|
  # CASED_POLICY_KEY=policy_live_1dQpY5JliYgHSkEntAbMVzuOROh
  config.policy_key = 'policy_live_1dQpY5JliYgHSkEntAbMVzuOROh'

  # CASED_USERS_POLICY_KEY=policy_live_1dQpY8bBgEwdpmdpVrrtDzMX4fH
  # CASED_ORGANIZATIONS_POLICY_KEY=policy_live_1dSHQRurWX8JMYMbkRdfzVoo62d
  config.policy_keys = {
    users: 'policy_live_1dQpY8bBgEwdpmdpVrrtDzMX4fH',
    organizations: 'policy_live_1dSHQRurWX8JMYMbkRdfzVoo62d',
  }

  # CASED_PUBLISH_KEY=publish_live_1dQpY1jKB48kBd3418PjAotmEwA
  config.publish_key = 'publish_live_1dQpY1jKB48kBd3418PjAotmEwA'

  # CASED_PUBLISH_URL=https://publish.cased.com
  config.publish_url = 'https://publish.cased.com'

  # CASED_URL=https://app.cased.com
  config.url = 'https://app.cased.com'

  # CASED_API_URL=https://api.cased.com
  config.api_url = 'https://api.cased.com'

  # GUARD_APPLICATION_KEY=guard_application_1ntKX0P4vUbKoc0lMWGiSbrBHcH
  config.guard_application_key = 'guard_application_1ntKX0P4vUbKoc0lMWGiSbrBHcH'

  # GUARD_USER_TOKEN=user_1oFqlROLNRGVLOXJSsHkJiVmylr
  config.guard_user_token = 'user_1oFqlROLNRGVLOXJSsHkJiVmylr'

  # DENY_IF_UNREACHABLE=1
  config.guard_deny_if_unreachable = true

  # CASED_RAISE_ON_ERRORS=1
  config.raise_on_errors = false

  # CASED_SILENCE=1
  config.silence = false

  # CASED_HTTP_OPEN_TIMEOUT=5
  config.http_open_timeout = 5

  # CASED_HTTP_READ_TIMEOUT=10
  config.http_read_timeout = 10

  # Attach metadata to all CLI requests. This metadata will appear in Cased and
  # any notification source such as email or Slack.
  #
  # You are limited to 20 properties and cannot be a nested dictionary. Metadata
  # specified in the CLI request overrides any configured globally.
  config.cli. = {
    rails_env: ENV['RAILS_ENV'],
    heroku_application: ENV['HEROKU_APP_NAME'],
    git_commit: ENV['GIT_COMMIT'],
  }
end

Usage

Cased CLI

Keep any command line tool available as your team grows — monitor usage, require peer approvals for sensitive operations, and receive intelligent alerts to suspicious activity.

Starting an approval workflow

To start an approval workflow you must first obtain your application key and the user token for who is requesting access.

Cased.configure do |config|
  config.guard_application_key = 'guard_application_1pG43HF3aRHjNTTm10zzu0tngBO'
end

authentication = Cased::CLI::Authentication.new(token: 'user_1pG43D1AzTjLR8XWJHj8B3aNZ4Y')
session = Cased::CLI::Session.new(
  authentication: authentication,
  reason: 'I need export our GitHub issues.',
  metadata: {
    organization: 'GitHub',
  },
)

if session.create && session.approved?
  github.issues.each do |issue|
    puts issue.title
  end
else
  puts 'Unauthorized to export GitHub issues.'
end

If you do not have the user token you can always request it interactively. Cased::CLI::Identity#identify is a blocking operation prompting the user to visit Cased to identify themselves, returning their user token upon identifying themselves which can be used to start your session.

Cased.configure do |config|
  config.guard_application_key = 'guard_application_1pG43HF3aRHjNTTm10zzu0tngBO'
end

authentication = Cased::CLI::Authentication.new
identity = Cased::CLI::Identity.new
token, ip_address = identity.identify
authentication.token = token

session = Cased::CLI::Session.new(
  authentication: authentication,
  reason: 'I need export our GitHub issues.',
  metadata: {
    organization: 'GitHub',
  },
)

if session.create && session.approved?
  github.issues.each do |issue|
    puts issue.title
  end
else
  puts 'Unauthorized to export GitHub issues.'
end

Starting an interactive approval workflow

If you do not want to manually create sessions and handle each state manually, you can use the interactive approval workflow using Cased::CLI::InteractiveSession.

Cased.configure do |config|
  config.guard_application_key = 'guard_application_1pG43HF3aRHjNTTm10zzu0tngBO'
end

session = Cased::CLI::InteractiveSession.start

if session.approved?
  github.issues.each do |issue|
    puts issue.title
  end
else
  puts 'Unauthorized to export GitHub issues.'
end

You no longer need to handle obtaining the user token or asking for a reason up front, Cased::CLI::InteractiveSession will prompt the user for any reason being required as necessary.

Attaching metadata to all CLI requests

While you can customize the metadata included for each CLI request, it may prove useful to specify metadata globally that will be included with each CLI request. Some useful information to include may be the current Rails environment, Heroku application, Git commit deployed, and more.

Metadata is limited to 20 properties and cannot be a nested dictionary.

Cased.configure do |config|
  config.cli. = {
    rails_env: ENV['RAILS_ENV'],
    heroku_application: ENV['HEROKU_APP_NAME'],
    git_commit: ENV['GIT_COMMIT'],
  }
end

Note: Metadata specified in the CLI request overrides any configured globally.

Audit trails

Publishing events to Cased

There are two ways to publish your first Cased event.

Manually

require 'cased-ruby'

Cased.configure do |config|
  config.publish_key = 'publish_live_1dQpY1jKB48kBd3418PjAotmEwA'
end

Cased.publish(
  action: 'credit_card.charge',
  amount: 2000,
  currency: 'usd',
  source: 'tok_amex',
  description: 'My First Test Charge (created for API docs)',
  credit_card_id: 'card_1dQpXqQwXxsQs9sohN9HrzRAV6y',
)

Cased::Model

cased-ruby provides a class mixin that gives you a framework to publish events.

require 'cased-ruby'

Cased.configure do |config|
  config.publish_key = 'publish_live_1dQpY1jKB48kBd3418PjAotmEwA'
end

class CreditCard
  include Cased::Model

  def initialize(amount:, currency:, source:, description:)
    @amount = amount
    @currency = currency
    @source = source
    @description = description
  end

  def charge
    Stripe::Charge.create({
      amount: @amount,
      currency: @currency,
      source: @source,
      description: @description,
    })

    cased(:charge, payload: {
      amount: @amount,
      currency: @currency,
      description: @description,
    })
  end

  def cased_id
    'card_1dQpXqQwXxsQs9sohN9HrzRAV6y'
  end

  def cased_payload
    {
      credit_card: self,
    }
  end
end

credit_card = CreditCard.new(
  amount: 2000,
  currency: 'usd',
  source: 'tok_amex',
  description: 'My First Test Charge (created for API docs)',
)

credit_card.charge

Both examples above are equivelent in that they publish the following credit_card.charge event to Cased:

{
  "cased_id": "5f8559cd-4cd9-48c3-b1d0-6eedc4019ec1",
  "action": "credit_card.charge",
  "amount": 2000,
  "currency": "usd",
  "source": "tok_amex",
  "description": "My First Test Charge (created for API docs)",
  "credit_card_id": "card_1dQpXqQwXxsQs9sohN9HrzRAV6y",
  "timestamp": "2020-06-23T02:02:39.932759Z"
}

Retrieving events from a Cased audit trail

If you plan on retrieving audit events from your Cased audit trail you must use a Cased API key.

require 'cased-ruby'

Cased.configure do |config|
  config.policy_key = 'policy_live_1dQpY5JliYgHSkEntAbMVzuOROh'
end

query = Cased.policy.events.limit(25).page(1)
results = query.results
results.each do |event|
  puts event['action'] # => credit_card.charge
  puts event['timestamp'] # => 2020-06-23T02:02:39.932759Z
end
query.total_count # => 2,366
query.total_pages # => 95
query.success? # => true
query.error? # => false

Retrieving events from multiple Cased audit trails

To retrieve audit events from one or more Cased audit trails you can configure multiple Cased Policy API keys and retrieve events for each one.

require 'cased-ruby'

Cased.configure do |config|
  config.policy_keys = {
    users: 'policy_live_1dQpY8bBgEwdpmdpVrrtDzMX4fH',
    organizations: 'policy_live_1dSHQRurWX8JMYMbkRdfzVoo62d',
  }
end

query = Cased.policies[:users].events.limit(25).page(1)
results = query.results
results.each do |event|
  puts event['action'] # => user.login
  puts event['timestamp'] # => 2020-06-23T02:02:39.932759Z
end

query = Cased.policies[:organizations].events.limit(25).page(1)
results = query.results
results.each do |event|
  puts event['action'] # => organization.create
  puts event['timestamp'] # => 2020-06-22T22:16:31.055655Z
end

Exporting events

Exporting events from Cased allows you to provide users with exports of their own data or to respond to data requests.

require 'cased-ruby'

Cased.configure do |config|
  config.policy_key = 'policy_live_1dQpY5JliYgHSkEntAbMVzuOROh'
end

export = Cased.policy.exports.create(
  format: :json,
  phrase: 'action:credit_card.charge',
)
export.download_url # => https://api.cased.com/exports/export_1dSHQSNtAH90KA8zGTooMnmMdiD/download?token=eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoidXNlcl8xZFFwWThiQmdFd2RwbWRwVnJydER6TVg0ZkgiLCJ

Masking & filtering sensitive information

If you are handling sensitive information on behalf of your users you should consider masking or filtering any sensitive information.

require 'cased-ruby'

Cased.configure do |config|
  config.publish_key = 'publish_live_1dQpY1jKB48kBd3418PjAotmEwA'
end

Cased.publish(
  action: 'credit_card.charge',
  user: Cased::Sensitive::String.new('[email protected]', label: :email)
)

Console Usage

Most Cased events will be created by users from actions on the website from custom defined events or lifecycle callbacks. The exception is any console session where models may generate Cased events as you start to modify records.

By default any console session will include the hostname of where the console session takes place. Since every event must have an actor, you must set the actor at the beginning of your console session. If you don't know the user, it's recommended you create a system/robot user.

# OTHER CONSOLE INITIALIZATION HERE
Cased.context.push(actor: @actor)

Disable publishing events

Although rare, there may be times where you wish to disable publishing events to Cased. To do so wrap your transaction inside of a Cased.disable block:

Cased.disable do
  user.cased(:login)
end

Or you can configure the entire process to disable publishing events.

CASED_DISABLE_PUBLISHING=1 bundle exec ruby crawl.rb

Context

One of the most easiest ways to publish detailed events to Cased is to push contextual information on to the Cased context.

require 'cased-ruby'

Cased.configure do |config|
  config.publish_key = 'publish_live_1dQpY1jKB48kBd3418PjAotmEwA'
end

Cased.context.merge(location: 'hostname.local')

Cased.publish(
  action: 'console.start',
  user: 'john',
)

Any information stored in Cased.context will be included anytime an event is published.

{
  "cased_id": "5f8559cd-4cd9-48c3-b1d0-6eedc4019ec1",
  "action": "user.login",
  "user": "john",
  "location": "hostname.local",
  "timestamp": "2020-06-22T21:43:06.157336"
}

You can provide Cased.context.merge a block and the context will only be present for the duration of the block:

Cased.context.merge(location: 'hostname.local') do
  # Will include { "location": "hostname.local" }
  Cased.publish(
    action: 'console.start',
    user: 'john',
  )
end

# Will not include { "location": "hostname.local" }
Cased.publish(
  action: 'console.end',
  user: 'john',
)

To clear/reset the context:

Cased.context.clear

Testing

cased-ruby provides a test helper class that you can use to test events are being published to Cased.

require 'test-helper'

class CreditCardTest < Test::Unit::TestCase
  include Cased::TestHelper

  def test_charging_credit_card_publishes_credit_card_create_event
    credit_card = CreditCard.new(
      amount: 2000,
      currency: 'usd',
      source: 'tok_amex',
      description: 'My First Test Charge (created for API docs)',
    )

    credit_card.charge

    assert_cased_events 1, action: 'credit_card.charge', amount: 2000
  end

  def test_charging_credit_card_publishes_credit_card_create_event_with_block
    credit_card = CreditCard.new(
      amount: 2000,
      currency: 'usd',
      source: 'tok_amex',
      description: 'My First Test Charge (created for API docs)',
    )

    assert_cased_events 1, action: 'credit_card.charge', amount: 2000 do
      credit_card.charge
    end
  end

  def test_charging_credit_card_with_zero_amount_does_not_publish_credit_card_create_event
    credit_card = CreditCard.new(
      amount: 0,
      currency: 'usd',
      source: 'tok_amex',
      description: 'My First Test Charge (created for API docs)',
    )

    assert_no_cased_events do
      credit_card.charge
    end
  end
end

Customizing cased-ruby

Out of the box cased-ruby takes care of serializing objects for you to the best of its ability, but you can customize cased-ruby should you like to fit your products needs.

Let's look at each of these methods independently as they all work together to create the event.

Cased::Model#cased

This method is what publishes events for you to Cased. You include information specific to a particular event when calling Cased::Model#cased:

class CreditCard
  include Cased::Model

  # ...

  def charge
    Stripe::Charge.create({
      amount: @amount,
      currency: @currency,
      source: @source,
      description: @description,
    })

    cased(:charge, payload: {
      amount: @amount,
      currency: @currency,
      description: @description,
    })
  end
end

Or you can customize information that is included anytime Cased::Model#cased is called in your class:

class CreditCard
  include Cased::Model

  # ...

  def charge
    Stripe::Charge.create({
      amount: @amount,
      currency: @currency,
      source: @source,
      description: @description,
    })

    cased(:charge)
  end

  def cased_payload
    {
      credit_card: self,
      amount: @amount,
      currency: @currency,
      description: @description,
    }
  end
end

Both examples are equivelent.

Cased::Model#cased_category

By default cased_category will use the underscore class name to generate the prefix for all events generated by this class. If you published a CreditCard#charge event it would be delivered to Cased credit_card.charge. If you want to customize what cased-ruby uses you can do so by re-opening the method:

class CreditCard
  include Cased::Model

  def cased_category
    :card
  end
end

Cased::Model#cased_id

Per our guide on Human and machine readable information for Designing audit trail events we encourage you to publish a unique identifier that will never change to Cased along with your events. This way when you retrieve events from Cased you'll be able to locate the corresponding object in your system.

class User
  include Cased::Model

  def cased_id
    database_id
  end
end

Cased::Model#cased_context

To assist you in publishing events to Cased that are consistent and predictable, cased-ruby attempts to build your cased_context as long as you implement either to_s or cased_id in your class:

class Plan
  include Cased::Model

  def initialize(name)
    @name = name
  end

  def cased_id
    database_id
  end

  def to_s
    @name
  end
end

plan = Plan.new('Free')
plan.to_s # => 'Free'
plan.cased_id # => 'plan_1dQpY1jKB48kBd3418PjAotmEwA'
plan.cased_context # => { plan: 'Free', plan_id: 'plan_1dQpY1jKB48kBd3418PjAotmEwA' }

If your class does not implement to_s it will only include cased_id:

class Plan
  include Cased::Model

  def initialize(name)
    @name = name
  end

  def cased_id
    database_id
  end
end

plan = Plan.new('Free')
plan.to_s # => '#<Plan:0x00007feadf63b7e0>'
plan.cased_context # => { plan_id: 'plan_1dQpY1jKB48kBd3418PjAotmEwA' }

Or you can customize it if your to_s implementation is not suitable for Cased:

class Plan
  include Cased::Model

  def initialize(name)
    @name = name
  end

  def cased_id
    'plan_1dQpY1jKB48kBd3418PjAotmEwA'
  end

  def to_s
    @name
  end

  def cased_context(category: cased_category)
    {
      "#{category}_id".to_sym => cased_id,
      category => @name.parameterize,
    }
  end
end

class CreditCard
  include Cased::Model

  def initialize(amount:, currency:, source:, description:)
    @amount = amount
    @currency = currency
    @source = source
    @description = description
  end

  def charge
    Stripe::Charge.create({
      amount: @amount,
      currency: @currency,
      source: @source,
      description: @description,
    })

    cased(:charge, payload: {
      amount: @amount,
      currency: @currency,
      description: @description,
    })
  end

  def plan
    Plan.new('Free')
  end

  def cased_id
    'card_1dQpXqQwXxsQs9sohN9HrzRAV6y'
  end

  def cased_payload
    {
      credit_card: self,
      plan: plan,
    }
  end
end

credit_card = CreditCard.new(
  amount: 2000,
  currency: 'usd',
  source: 'tok_amex',
  description: 'My First Test Charge (created for API docs)',
)

credit_card.charge

Results in:

{
  "cased_id": "5f8559cd-4cd9-48c3-b1d0-6eedc4019ec1",
  "action": "credit_card.charge",
  "credit_card": "personal",
  "credit_card_id": "card_1dQpXqQwXxsQs9sohN9HrzRAV6y",
  "plan": "Free",
  "plan_id": "plan_1dQpY1jKB48kBd3418PjAotmEwA",
  "timestamp": "2020-06-22T20:24:04.815758"
}

Managing multiple dependency versions

Github Actions is configured to test this gem against multiple versions of dependencies.

This is managed by specifying a lockfile with the correct set of dependencies. To add a new set of dependencies, copy the existing file at gemfile-locks/Gemfile.lock and then update the dependency in that new lockfile with something like:

bundle lock --lockfile=gemfile-locks/Gemfile-activesupport-7.lock --update activesupport

The new lockfile can be added to the matrix at .github/workflows/ruby.yml.

Contributing

  1. Fork it ( https://github.com/cased/cased-ruby/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request