feat: add scoped access token model for read-only API access

This commit is contained in:
Shivam Mishra
2026-05-20 13:19:37 +05:30
parent 1913ccadfa
commit b96c3df19f
5 changed files with 34 additions and 3 deletions
+8
View File
@@ -4,6 +4,7 @@
#
# id :bigint not null, primary key
# owner_type :string
# scope :string default("full"), not null
# token :string
# created_at :datetime not null
# updated_at :datetime not null
@@ -16,6 +17,13 @@
#
class AccessToken < ApplicationRecord
# Schema-wise this table can hold any number of tokens per owner, but we
# intentionally cap user-facing tokens at two: one `full` and one `read_only`.
# The User model's has_one associations + auto-create callbacks enforce the
# 1+1 contract; this validation only constrains the allowed scope values.
SCOPES = %w[full read_only].freeze
has_secure_token :token
belongs_to :owner, polymorphic: true
validates :scope, inclusion: { in: SCOPES }
end
+7 -2
View File
@@ -1,11 +1,16 @@
module AccessTokenable
extend ActiveSupport::Concern
# AccessToken is polymorphic and the table allows many rows per owner. By
# convention we expose at most two per User (one `full`, one `read_only`);
# AgentBot and PlatformApp keep their single `full` token. The scoped
# has_one below is what makes `owner.access_token` deterministic.
included do
has_one :access_token, as: :owner, dependent: :destroy_async
has_one :access_token, -> { where(scope: 'full') },
as: :owner, class_name: 'AccessToken', inverse_of: :owner, dependent: :destroy_async
after_create :create_access_token
end
def create_access_token
AccessToken.create!(owner: self)
AccessToken.create!(owner: self, scope: 'full')
end
end
+12
View File
@@ -84,6 +84,13 @@ class User < ApplicationRecord
encrypts :otp_secret, deterministic: true
encrypts :otp_backup_codes
# A user has exactly two AccessTokens by product contract: the `full` one
# from AccessTokenable, plus the `read_only` one declared here. The DB does
# not enforce this cap (polymorphic owner_id allows many rows); we rely on
# these scoped has_one + after_create pairs to keep the 1+1 invariant.
has_one :read_only_access_token, -> { where(scope: 'read_only') },
as: :owner, class_name: 'AccessToken', inverse_of: :owner, dependent: :destroy_async
has_many :account_users, dependent: :destroy_async
has_many :accounts, through: :account_users
accepts_nested_attributes_for :account_users
@@ -117,6 +124,7 @@ class User < ApplicationRecord
# rubocop:enable Rails/HasManyOrHasOneDependent
before_validation :set_password_and_uid, on: :create
after_create :create_read_only_access_token
after_destroy :remove_macros
scope :order_by_full_name, -> { order('lower(name) ASC') }
@@ -133,6 +141,10 @@ class User < ApplicationRecord
self.uid = email
end
def create_read_only_access_token
AccessToken.create!(owner: self, scope: 'read_only')
end
def assigned_inboxes
administrator? ? Current.account.inboxes : inboxes.where(account_id: Current.account.id)
end
@@ -0,0 +1,5 @@
class AddScopeToAccessTokens < ActiveRecord::Migration[7.1]
def change
add_column :access_tokens, :scope, :string, default: 'full', null: false
end
end
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_05_20_073722) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -24,6 +24,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.string "token"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "scope", default: "full", null: false
t.index ["owner_type", "owner_id"], name: "index_access_tokens_on_owner_type_and_owner_id"
t.index ["token"], name: "index_access_tokens_on_token", unique: true
end