Skip to main content

GraphQL Mutation Authorization

Work in Progress

The authorization layer is under active development. Implementation details and documentation may change as we iterate. For questions or issues, please reach out to the team.

This guide explains how to properly implement authorization for GraphQL mutations in Arc. Our authorization system combines ActionPolicy with a custom IAM engine for role-based access control.

Adding Authorization to a New Mutation

Step 1: Define the Permission

First, ensure the required permission exists in the inventory:

# modules/iam/app/permissions/inventory.yml
resources:
care_pod:
- action: delete
description: Delete Care Pods

Step 2: Create the Mutation

Create your mutation class inheriting from BaseMutation:

# app/graphql/mutations/delete_care_pod.rb
module Mutations
class DeleteCarePod < BaseMutation
field :care_pod, Types::CarePodType, null: true

argument :care_pod_id, String, required: true, loads: Types::CarePodType

def resolve(care_pod:)
care_pod.destroy!

{
errors: [],
}
end

def authorized?(care_pod:)
authorize! care_pod, to: 'care_pod:delete'
end

def load_care_pod(care_pod_id)
CarePod.find(care_pod_id)
end
end
end

Step 3: Policy Configuration (Optional)

By default, authorization will automatically delegate to the IAM engine via the can? method in ApplicationPolicy. You do not need to create a custom policy class unless you require custom business logic.

When a Custom Policy is Not Needed

For most cases, the default behavior is sufficient. ActionPolicy will automatically:

  1. Look for a policy class matching your resource (e.g., CarePodPolicy for CarePod objects)
  2. If no custom policy exists, fall back to ApplicationPolicy
  3. Use the can? method which delegates to the IAM engine

When to Create a Custom Policy

Create a custom policy class only if you need custom business logic before IAM checks. For example:

  • Feature flag checks before authorization
  • Complex business rules that go beyond simple role-based permissions

If you need custom logic, see the Advanced: Custom Logic Before IAM Checks section below for implementation details.

Creating a Custom Policy (If Needed)

If custom logic is required, create a policy class inheriting from ApplicationPolicy:

# app/policies/care_pod_policy.rb
class CarePodPolicy < ApplicationPolicy; end

Step 4: Update Role Permissions

Ensure the relevant roles have the required permission. Our system follows a deny-by-default approach, meaning users will receive authorization errors unless their role explicitly includes the permission:

# modules/iam/app/permissions/admin.yml
permissions:
care_pod:
delete: {}

Step 5: Add Tests

Write tests to verify both authorization and functionality of your mutation.

Create a test file in spec/graphql/mutations/ and use the shared authorization example from spec/support/graphql/mutation_authorization_shared_examples.rb. The shared example expects query and params to be defined:

# spec/graphql/mutations/delete_care_pod_spec.rb
describe Mutations::DeleteCarePod do
# ... setup code (query, params, etc.) ...

include_examples 'GQL: Returns an error when the user is unauthorized',
required_permission: 'care_pod:delete'

it 'deletes the care pod successfully' do
expect { result }.to change { CarePod.count }.by(-1)
end
end

Authorization Flow

When a mutation executes, the authorization flow follows these steps:

  1. Object Loading: The loads: option automatically loads the object using load_#{argument} method
  2. Object Read Authorization: When an object is loaded with loads:, GraphQL automatically calls the object type's authorized? method to ensure the current user has permission to "read" that object
  3. Mutation Authorization Check: The mutation's authorized? method is called with the loaded objects
  4. Policy Evaluation: ActionPolicy's authorize! calls the appropriate policy method
  5. IAM Engine Check: The policy uses can? method which delegates to the IAM engine
  6. Permission Validation: IAM engine checks role permissions against the inventory

This means that before your mutation's custom authorization logic even runs, GraphQL has already verified that the user can access the loaded objects. This provides an additional layer of security by ensuring users can only attempt operations on objects they're allowed to see.

Advanced: Custom Logic Before IAM Checks

Sometimes you need custom business logic before falling back to IAM permissions. Here's how to implement this pattern:

Example: User Self-Update

# app/graphql/mutations/update_user.rb
module Mutations
class UpdateUser < BaseMutation
# ... fields and arguments ...

def authorized?(user:, **args)
# `user` is the user being updated, not the current_user
authorize! user, to: :update?
end
end
end

# app/policies/user_policy.rb
class UserPolicy < ApplicationPolicy
def update?
# Custom logic: Users can always update their own information
# before checking IAM permissions
record.id == user.id || can?('user:update', record)
end
end

This pattern allows for adding custom checks before the IAM engine validates permissions. It is useful for scenarios where business rules require special handling, such as checking a Feature Flag.

Error Handling

Authorization Failures

When authorization fails, ActionPolicy raises ActionPolicy::Unauthorized exceptions, which are automatically handled by GraphQL-Ruby and returned as errors in the response.

Custom Error Messages

You can customize authorization error messages using i18n files. Define custom messages in your locale file:

# config/locales/actionpolicy/en-US.yml
en-US:
action_policy:
policy:
care_pod:
delete?: "You don't have permission to delete care pods"
user:
update?: "You are not authorized to update user information"

The message key should match the policy class name and method. For example, care_pod.delete? corresponds to the delete? method in CarePodPolicy. When authorization fails, ActionPolicy will automatically use these custom messages instead of the default ones.

Best Practices

1. Use Descriptive Permission Names

# Good
authorize! care_pod, to: 'care_pod:delete'

# Avoid
authorize! care_pod, to: 'destroy'

2. Implement Custom Load Methods When Needed

def load_care_pod(care_pod_id)
# Add custom loading logic, scoping, etc.
CarePod.where(organization: current_user.organization).find(care_pod_id)
end

3. Keep Authorization Logic in Policies

# Good: Logic in policy
def authorized?(user:)
authorize! user, to: :update?
end

# Avoid: Logic in mutation
def authorized?(user:)
user.id == current_user.id || authorize! user, to: 'user:update'
end

4. Document Custom Authorization Logic

def update?
# Business rule: Users should always be able to update their own profile
# regardless of their role permissions
record.id == user.id || can?('user:update', record)
end

Troubleshooting

Common Issues

  1. Permission Not Found: Ensure the permission exists in inventory.yml
  2. Role Not Mapped: Check that the user's role has the required permission in the role policy file
  3. Object Loading Fails: Verify the load_#{argument} method and object existence

Debugging Authorization

Use the IAM engine directly in Rails console to debug:

# Check if a user can perform an action
IAM::Engine.can?(user, IAM::Permission.from_str('care_pod:delete'), care_pod)

# Check if a role is mapped
IAM::Engine.role_mapped?(user.role)

References