Skip to content

Implement Role-Based Access Control for Robot Operations #8

@MadBomber

Description

@MadBomber

Problem

All robots can access and modify all memory nodes without any authorization checks. There's no access control mechanism to restrict what robots can do.

Current Implementation

Location: lib/htm.rb - No authorization anywhere

def add_node(key, value, ...)
  # No check: Can this robot add this type of node?
  # No check: Is this robot authorized to use this system?
  @long_term_memory.add(key: key, value: value, robot_id: @robot_id, ...)
end

def forget(key, confirm: :confirmed)
  # No check: Can this robot delete nodes created by other robots?
  @long_term_memory.delete(key)
end

def recall(timeframe:, topic:, ...)
  # No check: Can this robot access these memories?
  @long_term_memory.search(...)
end

Security Risks

1. Unauthorized Data Access

# Malicious robot can read all memories
malicious_robot = HTM.new(robot_name: "Hacker")
all_secrets = malicious_robot.recall(
  timeframe: "all time",
  topic: "password OR secret OR api_key"
)

2. Data Tampering

# Robot can delete other robots' memories
robot_a.add_node("decision_001", "Use PostgreSQL")
robot_b.forget("decision_001", confirm: :confirmed)  # Deletes A's decision!

3. Resource Exhaustion

# Robot can flood system with nodes
malicious_robot.add_node("spam_#{i}", "junk" * 10000) for i in 1..1000000

4. Privilege Escalation

# Regular robot stores admin-level decision
regular_robot.add_node("admin_override", "Grant full access", type: :decision)

Solution

Implement role-based access control (RBAC) for robot operations.

Proposed Architecture

1. Robot Roles

module HTM
  module Roles
    GUEST = :guest          # Read-only, limited search
    USER = :user            # Read/write own nodes
    CONTRIBUTOR = :contributor  # Read all, write own
    ADMIN = :admin          # Full access
    SYSTEM = :system        # Internal operations
  end

  # Permission matrix
  PERMISSIONS = {
    guest: {
      add_node: false,
      recall: { scope: :public_only },
      retrieve: { scope: :public_only },
      forget: false,
      stats: false
    },
    user: {
      add_node: { scope: :own },
      recall: { scope: :own },
      retrieve: { scope: :own },
      forget: { scope: :own },
      stats: false
    },
    contributor: {
      add_node: { scope: :own },
      recall: { scope: :all },
      retrieve: { scope: :all },
      forget: { scope: :own },
      stats: { scope: :own }
    },
    admin: {
      add_node: { scope: :all },
      recall: { scope: :all },
      retrieve: { scope: :all },
      forget: { scope: :all },
      stats: { scope: :all }
    }
  }
end

2. Authorization Module

class HTM
  module Authorization
    def self.can?(robot, action, resource = nil)
      role = robot.role
      perms = PERMISSIONS[role][action]

      return false if perms == false
      return true if perms == true

      # Scope-based authorization
      case perms[:scope]
      when :own
        resource.nil? || resource[:robot_id] == robot.robot_id
      when :public_only
        resource.nil? || resource[:visibility] == 'public'
      when :all
        true
      else
        false
      end
    end

    def self.authorize!(robot, action, resource = nil)
      unless can?(robot, action, resource)
        raise HTM::AuthorizationError,
          "Robot '#{robot.robot_name}' (role: #{robot.role}) not authorized to #{action}"
      end
    end
  end
end

3. Robot Registration with Roles

class HTM
  attr_reader :robot_id, :robot_name, :role

  def initialize(
    working_memory_size: 128_000,
    robot_id: nil,
    robot_name: nil,
    robot_role: :user,    # NEW: default role
    api_key: nil,          # NEW: authentication
    **opts
  )
    @robot_id = robot_id || SecureRandom.uuid
    @robot_name = robot_name || "robot_#{@robot_id[0..7]}"

    # Authenticate and determine role
    @role = authenticate_and_authorize(api_key, robot_role)

    # ...
  end

  private

  def authenticate_and_authorize(api_key, requested_role)
    # Option 1: API key-based auth
    if api_key
      validated_role = validate_api_key(api_key)
      return validated_role
    end

    # Option 2: Environment-based (development)
    if ENV['HTM_ROBOT_ROLE']
      return ENV['HTM_ROBOT_ROLE'].to_sym
    end

    # Default: user role
    :user
  end

  def validate_api_key(api_key)
    # Query database for API key
    with_connection do |conn|
      result = conn.exec_params(
        "SELECT role FROM robot_api_keys WHERE api_key = $1 AND revoked = false",
        [api_key]
      )

      raise HTM::AuthenticationError, "Invalid API key" if result.ntuples.zero?

      result.first['role'].to_sym
    end
  end
end

4. Apply Authorization to Operations

def add_node(key, value, type: nil, ...)
  # Authorization check
  Authorization.authorize!(self, :add_node)

  # Proceed with operation
  embedding = @embedding_service.embed(value)
  # ...
end

def forget(key, confirm: :confirmed)
  raise ArgumentError, "Must pass confirm: :confirmed" unless confirm == :confirmed

  # Fetch node to check ownership
  node = retrieve(key)
  raise HTM::NotFoundError, "Node not found: #{key}" unless node

  # Authorization check
  Authorization.authorize!(self, :forget, node)

  # Proceed with deletion
  @long_term_memory.delete(key)
  # ...
end

def recall(timeframe:, topic:, limit: 20, strategy: :vector)
  # Authorization check
  Authorization.authorize!(self, :recall)

  # Apply scope filter based on role
  nodes = @long_term_memory.search(
    timeframe: timeframe,
    query: topic,
    limit: limit,
    strategy: strategy,
    filter_robot_id: (@role == :user ? @robot_id : nil)
  )
  # ...
end

Database Schema Changes

-- Add role to robots table
ALTER TABLE robots ADD COLUMN role TEXT DEFAULT 'user';

-- API keys for authentication
CREATE TABLE robot_api_keys (
  id BIGSERIAL PRIMARY KEY,
  api_key TEXT UNIQUE NOT NULL,
  robot_id TEXT REFERENCES robots(id),
  role TEXT NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  expires_at TIMESTAMP,
  revoked BOOLEAN DEFAULT false
);

-- Add visibility to nodes for public/private distinction
ALTER TABLE nodes ADD COLUMN visibility TEXT DEFAULT 'private';
CREATE INDEX idx_nodes_visibility ON nodes(visibility);

Configuration Examples

# Guest robot (read-only)
guest = HTM.new(
  robot_name: "PublicReader",
  robot_role: :guest
)

# Regular user robot
user = HTM.new(
  robot_name: "Assistant",
  robot_role: :user,
  api_key: ENV['HTM_API_KEY']
)

# Admin robot
admin = HTM.new(
  robot_name: "SystemAdmin",
  robot_role: :admin,
  api_key: ENV['HTM_ADMIN_KEY']
)

Testing

class TestAuthorization < Minitest::Test
  def test_guest_cannot_add_nodes
    guest = HTM.new(robot_name: "Guest", robot_role: :guest)

    error = assert_raises(HTM::AuthorizationError) do
      guest.add_node("key", "value")
    end

    assert_match /not authorized to add_node/, error.message
  end

  def test_user_cannot_delete_other_robot_nodes
    user1 = HTM.new(robot_name: "User1", robot_role: :user)
    user2 = HTM.new(robot_name: "User2", robot_role: :user)

    user1.add_node("user1_key", "value")

    assert_raises(HTM::AuthorizationError) do
      user2.forget("user1_key", confirm: :confirmed)
    end
  end

  def test_admin_can_delete_any_node
    user = HTM.new(robot_name: "User", robot_role: :user)
    admin = HTM.new(robot_name: "Admin", robot_role: :admin, api_key: admin_key)

    user.add_node("user_key", "value")

    # Admin can delete
    assert_nothing_raised do
      admin.forget("user_key", confirm: :confirmed)
    end
  end

  def test_user_only_recalls_own_nodes
    user1 = HTM.new(robot_name: "User1", robot_role: :user)
    user2 = HTM.new(robot_name: "User2", robot_role: :user)

    user1.add_node("user1_key", "user1 data")
    user2.add_node("user2_key", "user2 data")

    # User1 should only see own nodes
    results = user1.recall(timeframe: "last week", topic: "data")
    assert results.all? { |n| n['robot_id'] == user1.robot_id }
  end
end

Migration Path

Phase 1: Additive (v0.3.0)

  • Add roles to robots table (default: 'user')
  • Add authorization module (disabled by default)
  • Add API key support

Phase 2: Opt-in (v0.4.0)

  • Enable authorization via config flag
  • Provide migration guide for existing deployments

Phase 3: Required (v1.0.0)

  • Authorization enabled by default
  • Remove bypass flag

Alternative: Simple Owner-Only Access

For simpler use cases, just restrict to owner:

def forget(key, confirm: :confirmed)
  node = retrieve(key)

  # Simple check: only owner can delete
  unless node['robot_id'] == @robot_id
    raise HTM::AuthorizationError,
      "Cannot delete node owned by robot '#{node['robot_id']}'"
  end

  @long_term_memory.delete(key)
end

Estimated Effort

6 hours:

  • 2h: Design and implement RBAC system
  • 1h: Database schema changes and migrations
  • 2h: Apply authorization to all operations
  • 1h: Testing and documentation

References

Priority: MEDIUM
Category: Security
Breaking Change: YES (if enabled by default)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions