diff --git a/app/models/access_token.rb b/app/models/access_token.rb index 3e98498b0..de3804b68 100644 --- a/app/models/access_token.rb +++ b/app/models/access_token.rb @@ -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 diff --git a/app/models/concerns/access_tokenable.rb b/app/models/concerns/access_tokenable.rb index 0bb8daa4f..66c9f437f 100644 --- a/app/models/concerns/access_tokenable.rb +++ b/app/models/concerns/access_tokenable.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 4aa38bbcd..f8eaebe9d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/db/migrate/20260520073722_add_scope_to_access_tokens.rb b/db/migrate/20260520073722_add_scope_to_access_tokens.rb new file mode 100644 index 000000000..809dcf40b --- /dev/null +++ b/db/migrate/20260520073722_add_scope_to_access_tokens.rb @@ -0,0 +1,5 @@ +class AddScopeToAccessTokens < ActiveRecord::Migration[7.1] + def change + add_column :access_tokens, :scope, :string, default: 'full', null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 9d9fe3cbc..11a4d22d2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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