acting_for 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 416659519f04a4cf5f2639684e6078fe51766cf1eb727ac7b9cde851207d4f9e
4
+ data.tar.gz: b976be543b26602045c4f0db0152bbf5e051c028c54f18f70e2aae335f702a9e
5
+ SHA512:
6
+ metadata.gz: 5173d14fb39328a07f28957a365a4dbafe845f8dfbc4b2a73045f57f2c69a40ba6f96de9306b21a77d7b16d9cd10b6f136e93567f771bb5c0df0df082f4f11b3
7
+ data.tar.gz: 31cb9911b3a8efa9d4a6e54a5c30fb4eff45f15e0f54a1007391727959d37b10503e8da45ff994cd26389d345d51e26651fa3fdc0f46c490f9ae503c91fb2bab
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ChangQuan Cui
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,475 @@
1
+ # ActingFor
2
+
3
+ **Add delegated authorization behind MCP tools and other agent-driven Rails actions.**
4
+
5
+ *Rails-native delegated authorization for AI agents.*
6
+
7
+ MCP can give AI clients a standard way to discover and call tools exposed by your Rails application.
8
+
9
+ ActingFor answers the next question:
10
+
11
+ > **Should this Agent be allowed to perform this Action on behalf of this Principal?**
12
+
13
+ AI AgentがPrincipalの代理として何をしてよいかを、委任された権限に基づいて制御するRails向け認可Gemです。
14
+
15
+ ## Status
16
+
17
+ > **Implemented and CI verified; Not released.** The Public API, migrations, models, constraints, automatic Audit persistence, and formal test suite are implemented. CI covers Ruby 3.4 / 4.0, Rails 8.0 / 8.1, and PostgreSQL 16. The runnable setup was verified in a new Rails application in [D230](docs/DECISIONS.md#d230-runnable-quick-start-implementation). ActingFor has not been published to RubyGems.
18
+
19
+ ## Why ActingFor?
20
+
21
+ Traditional Rails authorization usually answers:
22
+
23
+ > **May this user perform this action?**
24
+
25
+ Once an AI Agent can act through MCP, an API, or another integration, the application also needs to answer:
26
+
27
+ > **May this Agent perform this action on behalf of this user?**
28
+
29
+ For example, an MCP server might expose tools such as:
30
+
31
+ ```text
32
+ search_products
33
+ purchase_product
34
+ cancel_order
35
+ ```
36
+
37
+ Making those tools callable is only part of the problem. Before Rails executes `purchase_product`, it may still need to know:
38
+
39
+ ```text
40
+ Which Agent is acting?
41
+ For which Principal?
42
+ Was :purchase delegated?
43
+ Is ¥8,900 within the delegated limit?
44
+ Has the Delegation expired or been revoked?
45
+ Is human approval required?
46
+ ```
47
+
48
+ That is the layer ActingFor provides.
49
+
50
+ ## MCP + ActingFor in one picture
51
+
52
+ ```text
53
+ ChatGPT / Claude / AI Agent
54
+
55
+ │ MCP, API, or another integration
56
+
57
+ Rails Host Application
58
+
59
+ │ "purchase_product"
60
+
61
+ ActingFor
62
+
63
+ ├─ Which Agent?
64
+ ├─ Acting for whom?
65
+ ├─ Was :purchase delegated?
66
+ ├─ Do constraints pass?
67
+ ├─ Is the Delegation active?
68
+ └─ Is approval required?
69
+
70
+
71
+ allow / require_approval / deny
72
+
73
+
74
+ Rails Business Logic
75
+ ```
76
+
77
+ **MCP gives Agents a way to interact with your Rails application. ActingFor adds delegated authorization behind that interaction.**
78
+
79
+ ActingFor does not require MCP, and it does not replace MCP. The same authorization layer can sit behind REST APIs, background jobs, internal Agent workflows, or other interfaces.
80
+
81
+ ## What does ActingFor do?
82
+
83
+ ActingFor answers a focused question: may this authenticated Agent perform this Action on behalf of this Principal, for this Resource and trusted Context?
84
+
85
+ - **Principal**: the party on whose behalf an Agent acts.
86
+ - **Agent**: the separate actor requesting an Action.
87
+ - **Delegation**: authority and constraints granted by the Principal.
88
+ - **Decision**: `allow`, `deny`, or `require_approval`.
89
+
90
+ The Principal and Agent are always separate actors. ActingFor evaluates their Delegation; it does not authenticate the Agent or execute the business operation.
91
+
92
+ ## MCP vs ActingFor
93
+
94
+ | | MCP | ActingFor |
95
+ | --- | --- | --- |
96
+ | Main role | Connect AI clients to application capabilities | Decide what an Agent may do for a Principal |
97
+ | Example | Call `purchase_product` | Decide whether that Agent may purchase |
98
+ | Agent authentication | May participate through the surrounding auth flow | Not provided by ActingFor |
99
+ | Delegation | Not ActingFor-style delegated authority | Core responsibility |
100
+ | Constraints / expiry / revocation | Not ActingFor's role | Core responsibility |
101
+ | Authorization audit | Not ActingFor's role | Automatic AuditEvent persistence |
102
+
103
+ MCP tool names and ActingFor Actions are different concepts. An adapter or host layer may translate an MCP tool call into `agent`, `principal`, `action`, `resource`, and trusted `context`.
104
+
105
+ See the [formal responsibility boundary](docs/PROJECT.md#21-actingforとmcpの正式な責務境界).
106
+
107
+ ## Delegated purchase example
108
+
109
+ A Principal can give an Agent only part of their authority:
110
+
111
+ ```text
112
+ User A
113
+
114
+ │ delegates
115
+
116
+ Shopping Agent
117
+
118
+ ├─ search_products allowed by the host as appropriate
119
+ ├─ purchase <= ¥10,000 delegated
120
+ ├─ purchase > ¥10,000 not covered by this Delegation
121
+ └─ delete_account not delegated
122
+ ```
123
+
124
+ At runtime:
125
+
126
+ ```text
127
+ Principal (User)
128
+ │ delegates a constrained :purchase
129
+
130
+ Shopping Agent
131
+ │ requests a purchase
132
+
133
+ Rails Host Application
134
+ │ authenticates the Agent, resolves the Principal,
135
+ │ loads the Product, and establishes trusted Context
136
+
137
+ ActingFor.authorize(...)
138
+ │ evaluates Delegation, constraints, expiry, and revocation
139
+ │ saves an AuditEvent automatically
140
+
141
+ allow / require_approval / deny
142
+
143
+
144
+ Rails Host Application enforces the Decision
145
+ ```
146
+
147
+ ## 30-second example
148
+
149
+ The host has already authenticated an external caller, resolved it to the local `shopping_agent`, resolved `user`, and loaded `product`:
150
+
151
+ ```ruby
152
+ ActingFor.delegate(
153
+ agent: shopping_agent,
154
+ principal: user,
155
+ action: :purchase,
156
+ resource: Product,
157
+ constraints: [
158
+ { field: "amount", operator: "lte", value: 10_000 }
159
+ ],
160
+ effect: :allow
161
+ )
162
+
163
+ decision = ActingFor.authorize(
164
+ agent: shopping_agent,
165
+ principal: user,
166
+ action: :purchase,
167
+ resource: product,
168
+ context: { amount: product.price }
169
+ )
170
+
171
+ decision.allowed? # => true when product.price is 8_900
172
+ ```
173
+
174
+ Both calls use the implemented [v0.1 Public API](docs/public_api_v0_1.md). Context values that affect authorization must be established by the host, not trusted directly from an Agent request.
175
+
176
+ ## Quick Start
177
+
178
+ ActingFor is not yet published to RubyGems. This pre-release setup installs the public GitHub `main` branch.
179
+
180
+ 1. Add the Gem and install dependencies:
181
+
182
+ ```ruby
183
+ gem "acting_for", github: "cuichangquan/acting_for", branch: "main"
184
+ # Rails 8.0 uses JSON options removed in JSON 3.
185
+ gem "json", "< 3"
186
+ ```
187
+
188
+ ```sh
189
+ bundle install
190
+ ```
191
+
192
+ 2. Install migrations:
193
+
194
+ ```sh
195
+ bin/rails acting_for:install:migrations
196
+ bin/rails db:migrate
197
+ ```
198
+
199
+ 3. In `bin/rails console`, create the local Agent:
200
+
201
+ ```ruby
202
+ shopping_agent = ActingFor::Agent.create!(
203
+ identifier: "shopping-agent",
204
+ name: "Shopping Agent"
205
+ )
206
+ ```
207
+
208
+ 4. Create a Delegation:
209
+
210
+ ```ruby
211
+ ActingFor.delegate(
212
+ agent: shopping_agent,
213
+ principal: user,
214
+ action: :purchase,
215
+ resource: Product,
216
+ constraints: [
217
+ { field: "amount", operator: "lte", value: 10_000 }
218
+ ],
219
+ effect: :allow
220
+ )
221
+ ```
222
+
223
+ 5. Authorize with host-verified Context:
224
+
225
+ ```ruby
226
+ product = Product.find_by!(price: 8_900)
227
+ decision = ActingFor.authorize(
228
+ agent: shopping_agent,
229
+ principal: user,
230
+ action: :purchase,
231
+ resource: product,
232
+ context: { amount: product.price }
233
+ )
234
+ ```
235
+
236
+ 6. Check the Decision before the host performs any operation:
237
+
238
+ ```ruby
239
+ [decision.status, decision.allowed?, decision.denied?, decision.approval_required?]
240
+ # => [:allow, true, false, false]
241
+ ```
242
+
243
+ For the complete runnable setup—including a new Rails application, public GitHub pre-release installation, host models, all three `¥8,900 / ¥20,000 / ¥50,000` outcomes, Audit behavior, and production security guidance—see [Getting Started](docs/getting_started.md).
244
+
245
+ ## How does an external AI Agent reach ActingFor?
246
+
247
+ ActingFor starts **after** the Rails Host Application knows which Agent is making the request.
248
+
249
+ An external caller such as ChatGPT, Claude, an MCP client, or a custom Agent must first be authenticated or otherwise reliably identified by the host application. The host then resolves that external identity to a local `ActingFor::Agent`.
250
+
251
+ ```text
252
+ External AI Agent
253
+ (ChatGPT / Claude / MCP Client / custom Agent)
254
+
255
+ │ OAuth token / JWT / API credential /
256
+ │ another trusted authentication mechanism
257
+
258
+ Rails Host Application
259
+
260
+ ├─ 1. Authenticate or identify the external Agent
261
+ ├─ 2. Resolve it to an ActingFor::Agent
262
+ ├─ 3. Resolve the Principal
263
+ ├─ 4. Load the Resource and trusted Context
264
+
265
+ ActingFor.authorize(...)
266
+
267
+ ├─ Delegation
268
+ ├─ Constraints
269
+ ├─ Expiration
270
+ └─ Revocation
271
+
272
+ allow / require_approval / deny
273
+ ```
274
+
275
+ **Authentication answers “Which Agent is this?” ActingFor answers “What may this Agent do on behalf of this Principal?”**
276
+
277
+ ### Agent and User are different records
278
+
279
+ An Agent does not need a second record in the host application's `users` table.
280
+
281
+ ```text
282
+ users
283
+ └─ User A ← Principal
284
+
285
+ acting_for_agents
286
+ └─ Shopping Agent ← Agent
287
+
288
+ acting_for_delegations
289
+ └─ User A → Shopping Agent → purchase
290
+ ```
291
+
292
+ Conceptually:
293
+
294
+ ```ruby
295
+ user
296
+ # => #<User id: 1>
297
+
298
+ shopping_agent
299
+ # => #<ActingFor::Agent id: 10>
300
+ ```
301
+
302
+ The host may call these values `current_user` and `current_agent`, but ActingFor does not provide a `current_agent` authentication helper. Agent authentication and external-identity resolution belong to the host application.
303
+
304
+ ### Responsibility split
305
+
306
+ | Question | Responsibility |
307
+ | --- | --- |
308
+ | Which external Agent sent this request? | Host application / authentication layer |
309
+ | Which `ActingFor::Agent` does that identity map to? | Host application |
310
+ | Which Principal is the Agent acting for? | Host application + Delegation relationship |
311
+ | May this Agent perform `purchase` for that Principal? | ActingFor |
312
+ | Do amount, expiry, and revocation constraints pass? | ActingFor |
313
+ | Should the business operation actually run? | Host application |
314
+
315
+ ### Do I need Agent Authentication before installing ActingFor?
316
+
317
+ No. ActingFor can be installed independently.
318
+
319
+ However, if an external AI Agent will call the Rails application directly, the host must authenticate or otherwise reliably identify that Agent and resolve it to an `ActingFor::Agent` before calling `ActingFor.authorize(...)`.
320
+
321
+ If the application already knows the Agent through an internal trusted workflow, ActingFor can be used without adding an external Agent authentication system first.
322
+
323
+ ### Example Rails stack: Devise + Doorkeeper + MCP + ActingFor
324
+
325
+ A Rails application can combine familiar components without making ActingFor responsible for authentication or MCP transport.
326
+
327
+ > **This is one possible Rails integration, not a required ActingFor stack.**
328
+
329
+ ```text
330
+ Human User
331
+
332
+ │ Devise
333
+ │ authenticate the human
334
+
335
+ Rails Host Application
336
+
337
+ │ Doorkeeper / OAuth
338
+ │ issue and validate access tokens
339
+
340
+ MCP Client / AI Agent
341
+
342
+ │ MCP tool call
343
+ │ purchase_product(product_id)
344
+
345
+ Rails MCP endpoint
346
+
347
+ ├─ validate the surrounding authentication / OAuth context
348
+ ├─ resolve the external identity to an ActingFor::Agent
349
+ ├─ resolve the Principal
350
+ ├─ load the Resource
351
+ └─ establish trusted Context
352
+
353
+ ActingFor.authorize(...)
354
+
355
+ ├─ Was :purchase delegated?
356
+ ├─ Do constraints pass?
357
+ ├─ Is the Delegation active?
358
+ └─ Is approval required?
359
+
360
+ allow / require_approval / deny
361
+
362
+
363
+ Rails Business Logic
364
+ ```
365
+
366
+ The responsibilities remain separate:
367
+
368
+ | Component | Example responsibility |
369
+ | --- | --- |
370
+ | Devise | Authenticate the human User / Principal |
371
+ | Doorkeeper | Provide OAuth authorization and access-token handling for the Rails application |
372
+ | MCP | Expose Rails capabilities such as `purchase_product` to AI clients |
373
+ | Rails host application | Validate the caller context, resolve Agent + Principal, load trusted business data, and enforce the Decision |
374
+ | ActingFor | Decide what the resolved Agent may do on behalf of the resolved Principal |
375
+
376
+ The important boundary is that **MCP makes a capability callable, while ActingFor decides whether the resolved Agent may use that capability for this Principal**.
377
+
378
+ An illustrative host-side mapping might look like:
379
+
380
+ ```ruby
381
+ oauth_application = doorkeeper_token.application
382
+ user = User.find(doorkeeper_token.resource_owner_id)
383
+
384
+ shopping_agent = ActingFor::Agent.find_by!(
385
+ identifier: oauth_application.uid
386
+ )
387
+
388
+ decision = ActingFor.authorize(
389
+ agent: shopping_agent,
390
+ principal: user,
391
+ action: :purchase,
392
+ resource: product,
393
+ context: { amount: product.price }
394
+ )
395
+ ```
396
+
397
+ This example is intentionally simplified. A Doorkeeper application or OAuth client is **not necessarily identical to one AI Agent instance**. For example, one MCP client may front multiple logical Agents. Production applications may therefore need an explicit identity-mapping layer:
398
+
399
+ ```text
400
+ Authenticated OAuth / MCP identity
401
+
402
+ Rails identity mapping
403
+
404
+ ActingFor::Agent
405
+
406
+ Principal + Delegation
407
+
408
+ ActingFor.authorize(...)
409
+ ```
410
+
411
+ ActingFor does not require Devise, Doorkeeper, or MCP. Existing applications may use another authentication provider, OAuth/OIDC service, API credential, transport, or trusted internal workflow as long as the host can reliably establish the Agent and Principal before calling ActingFor.
412
+
413
+ ## Security / Responsibility Boundary
414
+
415
+ ```text
416
+ Host Authorization
417
+ AND
418
+ ActingFor Authorization
419
+
420
+ Business Logic
421
+ ```
422
+
423
+ The host must authenticate the Agent, resolve the Principal, check the Principal's current permissions, provide trusted Context, enforce the returned Decision, and execute the business operation. ActingFor does not call host authorization libraries directly.
424
+
425
+ Important parts of the security contract:
426
+
427
+ - Agent and Principal are separate actors. ActingFor does not authenticate Agents or provide login/session management.
428
+ - `require_approval != allow`; approval-required operations must not execute automatically.
429
+ - `ActingFor::Decision` describes the result at authorization time. It is not a reusable authorization token or capability.
430
+ - Authorize close to the protected operation. Do not reuse cached Decisions or cached Delegations as authorization proof.
431
+ - ActingFor automatically persists an AuditEvent inside `authorize`. If Audit persistence fails, no Decision is returned and the host must not proceed.
432
+ - ActingFor does not execute business operations or guarantee atomicity with them. The host owns transaction, concurrency, retry, and TOCTOU handling.
433
+
434
+ See the [Getting Started security guidance](docs/getting_started.md#security-and-responsibility-boundary) and formal [Security Model](docs/security_model_v0_1.md).
435
+
436
+ ## What ActingFor is not
437
+
438
+ ActingFor is not an authentication provider, OAuth/OIDC server, Agent framework, MCP server, approval workflow, payment system, or general-purpose policy engine. It does not fetch or validate business data, provision external Agents, or perform the authorized operation.
439
+
440
+ ## Official Demo
441
+
442
+ [ActingFor Demo](https://github.com/cuichangquan/acting_for_demo) is the official real Rails host application. It demonstrates Public API integration, automated host-integration verification, and a workflow for human manual verification. The gem repository remains the source of truth for gem behavior, the Public API, and the Security Contract; the demo verifies those public boundaries without depending on `ActingFor::Internal::*`.
443
+
444
+ ## Documentation
445
+
446
+ Project documents are maintained primarily in Japanese:
447
+
448
+ - [Getting Started](docs/getting_started.md)
449
+ - [Current State](docs/CURRENT_STATE.md)
450
+ - [Progress](docs/PROGRESS.md)
451
+ - [Project scope, roadmap, and terminology](docs/PROJECT.md)
452
+ - [Decisions and rationale](docs/DECISIONS.md)
453
+ - [v0.1 Public API](docs/public_api_v0_1.md)
454
+ - [v0.1 Security Model](docs/security_model_v0_1.md)
455
+ - [v0.1 Domain Model](docs/domain_model_v0_1.md)
456
+ - [v0.1 Gem Structure](docs/gem_structure_v0_1.md)
457
+ - [v0.1 Test Strategy](docs/test_strategy_v0_1.md)
458
+ - [GitHub Issues](https://github.com/cuichangquan/acting_for/issues)
459
+
460
+ ## Supported Versions
461
+
462
+ The formal GitHub Actions matrix verifies PostgreSQL 16 with:
463
+
464
+ | Ruby | Rails |
465
+ | --- | --- |
466
+ | 3.4 | 8.0 |
467
+ | 3.4 | 8.1 |
468
+ | 4.0 | 8.0 |
469
+ | 4.0 | 8.1 |
470
+
471
+ Other database adapters are outside v0.1 official support. Formal Minitest and core RuboCop are CI gates. See the [support policy](docs/PROJECT.md#46-対応環境公開方針d045d048).
472
+
473
+ ## License
474
+
475
+ ActingFor is available under the [MIT License](LICENSE).
@@ -0,0 +1,29 @@
1
+ module ActingFor
2
+ class Agent < ApplicationRecord
3
+ has_many :delegations,
4
+ class_name: "ActingFor::Delegation",
5
+ dependent: :restrict_with_exception
6
+
7
+ validates :identifier, uniqueness: { case_sensitive: true }
8
+ validate :identifier_format
9
+ validate :name_format
10
+
11
+ private
12
+
13
+ def identifier_format
14
+ value = read_attribute_before_type_cast(:identifier)
15
+ return if value.is_a?(String) && value.length.between?(1, 255) && !value.match?(/[[:space:]]/)
16
+
17
+ errors.add(:identifier, "must be a String of 1..255 characters without whitespace")
18
+ end
19
+
20
+ def name_format
21
+ value = read_attribute_before_type_cast(:name)
22
+ return if value.nil?
23
+
24
+ return if value.is_a?(String) && value.length.between?(1, 255) && !value.blank?
25
+
26
+ errors.add(:name, "must be a nonblank String of 1..255 characters")
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ module ActingFor
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,50 @@
1
+ module ActingFor
2
+ class AuditEvent < ApplicationRecord
3
+ DECISION_REASONS = {
4
+ "allow" => "delegation_allowed",
5
+ "require_approval" => "delegation_requires_approval",
6
+ "deny" => "no_matching_delegation"
7
+ }.freeze
8
+
9
+ validates :agent_id, :agent_identifier, :principal_type, :principal_id,
10
+ :action, :decision, :reason_code, presence: true
11
+ validates :decision, inclusion: { in: DECISION_REASONS.keys }
12
+ validates :reason_code, inclusion: { in: DECISION_REASONS.values }
13
+ validate :decision_reason_pair
14
+ validate :resource_scope
15
+ validate :matched_delegations
16
+ validate :context_format
17
+
18
+ def readonly?
19
+ persisted? || super
20
+ end
21
+
22
+ private
23
+
24
+ def decision_reason_pair
25
+ errors.add(:reason_code, "does not match decision") unless DECISION_REASONS[decision] == reason_code
26
+ end
27
+
28
+ def resource_scope
29
+ errors.add(:resource_type, "is required when resource_id is set") if resource_type.nil? && !resource_id.nil?
30
+ end
31
+
32
+ def matched_delegations
33
+ ids = matched_delegation_ids
34
+ unless ids.is_a?(Array) && ids.all?(Integer) && ids.uniq.length == ids.length
35
+ errors.add(:matched_delegation_ids, "must be an Array of unique Integers")
36
+ return
37
+ end
38
+
39
+ if decision == "deny" && !ids.empty?
40
+ errors.add(:matched_delegation_ids, "must be empty for deny")
41
+ elsif %w[allow require_approval].include?(decision) && ids.empty?
42
+ errors.add(:matched_delegation_ids, "must not be empty for allow or require_approval")
43
+ end
44
+ end
45
+
46
+ def context_format
47
+ errors.add(:sanitized_context, "must be a Hash") unless sanitized_context.is_a?(Hash)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,74 @@
1
+ module ActingFor
2
+ class Delegation < ApplicationRecord
3
+ IMMUTABLE_ATTRIBUTES = %w[
4
+ agent_id principal_type principal_id action resource_type resource_id
5
+ constraints effect expires_at revoked_at
6
+ ].freeze
7
+ CONSTRAINT_KEYS = %w[field operator value].freeze
8
+
9
+ belongs_to :agent, class_name: "ActingFor::Agent"
10
+ belongs_to :principal, polymorphic: true
11
+
12
+ validates :agent, :principal, :action, :effect, presence: true
13
+ validates :effect, inclusion: { in: %w[allow require_approval] }
14
+ validate :resource_scope
15
+ validate :canonical_constraints
16
+ validate :initial_timestamps, on: :create
17
+ validate :immutable_attributes, on: :update
18
+
19
+ def revoke!
20
+ raise ActiveRecord::RecordNotSaved.new("Cannot revoke an unsaved delegation", self) unless persisted?
21
+
22
+ time = ActingFor.current_time
23
+ self.class.where(id: id, revoked_at: nil).update_all(revoked_at: time, updated_at: time)
24
+ reload
25
+ end
26
+
27
+ private
28
+
29
+ def resource_scope
30
+ errors.add(:resource_type, "is required when resource_id is set") if resource_type.nil? && !resource_id.nil?
31
+ end
32
+
33
+ def initial_timestamps
34
+ errors.add(:expires_at, "must be in the future") if expires_at && expires_at <= ActingFor.current_time
35
+ errors.add(:revoked_at, "must be nil on creation") unless revoked_at.nil?
36
+ end
37
+
38
+ def immutable_attributes
39
+ IMMUTABLE_ATTRIBUTES.each do |attribute|
40
+ errors.add(attribute, "cannot be changed") if will_save_change_to_attribute?(attribute)
41
+ end
42
+ end
43
+
44
+ def canonical_constraints
45
+ # JSON casting stringifies Hash keys; also check the original assigned value.
46
+ values = [constraints]
47
+ values << read_attribute_before_type_cast(:constraints) if constraints_came_from_user?
48
+ return if values.all? { |value| value.is_a?(Array) && value.all? { |entry| canonical_constraint?(entry) } }
49
+
50
+ errors.add(:constraints, "must be an Array of canonical constraints")
51
+ end
52
+
53
+ def canonical_constraint?(entry)
54
+ return false unless entry.is_a?(Hash) && entry.keys.size == 3 && (CONSTRAINT_KEYS - entry.keys).empty?
55
+ return false unless entry["field"].is_a?(String) && !entry["field"].empty?
56
+
57
+ value = entry["value"]
58
+ case entry["operator"]
59
+ when "eq"
60
+ scalar_constraint_value?(value)
61
+ when "lt", "lte", "gt", "gte"
62
+ value.is_a?(Integer)
63
+ when "in"
64
+ value.is_a?(Array) && value.all? { |element| scalar_constraint_value?(element) }
65
+ else
66
+ false
67
+ end
68
+ end
69
+
70
+ def scalar_constraint_value?(value)
71
+ value.is_a?(String) || value.is_a?(Integer) || value.is_a?(TrueClass) || value.is_a?(FalseClass)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,175 @@
1
+ require "bigdecimal"
2
+
3
+ module ActingFor
4
+ module Internal
5
+ class Authorization
6
+ FORBIDDEN_AUDIT_CONTEXT_KEYS = %i[
7
+ password password_confirmation token access_token refresh_token
8
+ api_key secret client_secret credential
9
+ ].freeze
10
+ private_constant :FORBIDDEN_AUDIT_CONTEXT_KEYS
11
+
12
+ class << self
13
+ def call(agent:, principal:, action:, resource:, context:, audit_context_keys:)
14
+ unless agent.is_a?(ActingFor::Agent) && agent.persisted?
15
+ raise InvalidRequestError, "agent must be a persisted ActingFor::Agent"
16
+ end
17
+ unless principal.is_a?(ActiveRecord::Base) && principal.persisted?
18
+ raise InvalidRequestError, "principal must be a persisted ActiveRecord record"
19
+ end
20
+
21
+ action = string_value(action, "action")
22
+ raise InvalidRequestError, "action must not be blank" if action.blank?
23
+
24
+ resource_type, resource_id = resource_identity(resource)
25
+ raise InvalidRequestError, "context must be a Hash" unless context.is_a?(Hash)
26
+
27
+ audit_context = sanitized_context(context, audit_context_keys)
28
+
29
+ matches = candidate_delegations(
30
+ agent: agent,
31
+ principal: principal,
32
+ action: action,
33
+ resource_type: resource_type
34
+ ).select do |delegation|
35
+ resource_matches?(delegation, resource_type, resource_id) &&
36
+ ConstraintEvaluator.call(constraints: delegation.constraints, context: context)
37
+ end
38
+
39
+ decision = ActingFor::Decision.new(decision_status(matches))
40
+ audit_attributes = audit_event_attributes(
41
+ agent: agent,
42
+ principal: principal,
43
+ action: action,
44
+ resource_type: resource_type,
45
+ resource_id: resource_id,
46
+ decision: decision,
47
+ matches: matches,
48
+ sanitized_context: audit_context
49
+ )
50
+ persist_audit_event!(audit_attributes)
51
+
52
+ decision
53
+ end
54
+
55
+ private
56
+
57
+ def string_value(value, name)
58
+ return value if value.is_a?(String)
59
+ return value.to_s if value.is_a?(Symbol)
60
+
61
+ raise InvalidRequestError, "#{name} must be a String or Symbol"
62
+ end
63
+
64
+ def resource_identity(resource)
65
+ return [nil, nil] if resource.nil?
66
+
67
+ klass = resource.is_a?(Class) ? resource : resource.class
68
+ raise InvalidRequestError, "resource class must provide model_name" unless klass.respond_to?(:model_name)
69
+
70
+ model_name = klass.model_name
71
+ raise InvalidRequestError, "resource model_name must provide name" unless model_name.respond_to?(:name)
72
+
73
+ resource_type = model_name.name
74
+ return [resource_type, nil] if resource.is_a?(Class)
75
+
76
+ raise InvalidRequestError, "resource instance must provide id" unless resource.respond_to?(:id)
77
+
78
+ id = resource.id
79
+ raise InvalidRequestError, "resource id must not be nil" if id.nil?
80
+
81
+ resource_id = id.to_s
82
+ raise InvalidRequestError, "resource id must not be empty" if resource_id.empty?
83
+
84
+ [resource_type, resource_id]
85
+ end
86
+
87
+ def sanitized_context(context, audit_context_keys)
88
+ unless audit_context_keys.is_a?(Array) &&
89
+ audit_context_keys.all?(Symbol)
90
+ raise InvalidRequestError, "audit_context_keys must be an Array of Symbols"
91
+ end
92
+
93
+ keys = audit_context_keys.uniq
94
+ forbidden_key = keys.find { |key| FORBIDDEN_AUDIT_CONTEXT_KEYS.include?(key) }
95
+ raise InvalidRequestError, "audit_context_keys contains forbidden key: #{forbidden_key}" if forbidden_key
96
+
97
+ keys.each_with_object({}) do |key, sanitized|
98
+ next unless exact_symbol_key?(context, key)
99
+
100
+ sanitized[key.to_s] = sanitized_audit_value(context[key], key)
101
+ end
102
+ end
103
+
104
+ def exact_symbol_key?(context, key)
105
+ context.each_key.any? { |existing_key| existing_key.is_a?(Symbol) && existing_key == key }
106
+ end
107
+
108
+ def sanitized_audit_value(value, key)
109
+ return value.to_s("F") if value.is_a?(BigDecimal)
110
+ return value if value.nil? ||
111
+ value.is_a?(String) ||
112
+ value.is_a?(Integer) ||
113
+ value.is_a?(Float) ||
114
+ value.is_a?(TrueClass) ||
115
+ value.is_a?(FalseClass)
116
+
117
+ raise InvalidRequestError, "unsupported audit context value for #{key}"
118
+ end
119
+
120
+ def candidate_delegations(agent:, principal:, action:, resource_type:)
121
+ ActingFor::Delegation
122
+ .where(
123
+ agent: agent,
124
+ principal: principal,
125
+ action: action,
126
+ resource_type: resource_type,
127
+ revoked_at: nil
128
+ )
129
+ .where("expires_at IS NULL OR expires_at > ?", ActingFor.current_time)
130
+ end
131
+
132
+ def resource_matches?(delegation, resource_type, resource_id)
133
+ return delegation.resource_id.nil? && resource_id.nil? if resource_type.nil?
134
+ return false unless delegation.resource_type == resource_type
135
+ return delegation.resource_id.nil? if resource_id.nil?
136
+
137
+ delegation.resource_id.nil? || delegation.resource_id == resource_id
138
+ end
139
+
140
+ def decision_status(matches)
141
+ return :require_approval if matches.any? { |delegation| delegation.effect == "require_approval" }
142
+ return :allow if matches.any? { |delegation| delegation.effect == "allow" }
143
+
144
+ :deny
145
+ end
146
+
147
+ def audit_event_attributes(
148
+ agent:, principal:, action:, resource_type:, resource_id:, decision:, matches:, sanitized_context:
149
+ )
150
+ decision_value = decision.status.to_s
151
+
152
+ {
153
+ agent_id: agent.id,
154
+ agent_identifier: agent.identifier,
155
+ principal_type: principal.class.polymorphic_name,
156
+ principal_id: principal.id.to_s,
157
+ action: action,
158
+ resource_type: resource_type,
159
+ resource_id: resource_id,
160
+ decision: decision_value,
161
+ reason_code: ActingFor::AuditEvent::DECISION_REASONS.fetch(decision_value),
162
+ matched_delegation_ids: matches.map(&:id).sort,
163
+ sanitized_context: sanitized_context
164
+ }
165
+ end
166
+
167
+ def persist_audit_event!(attributes)
168
+ ActingFor::AuditEvent.create!(attributes)
169
+ rescue ActiveRecord::ActiveRecordError => e
170
+ raise ActingFor::AuditPersistenceError, "Failed to persist audit event", cause: e
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,92 @@
1
+ module ActingFor
2
+ module Internal
3
+ class ConstraintEvaluator
4
+ CONSTRAINT_KEYS = %w[field operator value].freeze
5
+ private_constant :CONSTRAINT_KEYS
6
+
7
+ class << self
8
+ def call(constraints:, context:)
9
+ return false unless constraints.is_a?(Array) && context.is_a?(Hash)
10
+
11
+ constraints.all? { |constraint| constraint_matches?(constraint, context) }
12
+ end
13
+
14
+ private
15
+
16
+ def constraint_matches?(constraint, context)
17
+ return false unless canonical_constraint?(constraint)
18
+
19
+ field = constraint["field"]
20
+ key = field.to_sym
21
+ return false unless context.key?(key)
22
+
23
+ actual = context[key]
24
+ return false if actual.nil?
25
+
26
+ expected = constraint["value"]
27
+
28
+ case constraint["operator"]
29
+ when "eq"
30
+ same_scalar_type?(actual, expected) && actual == expected
31
+ when "lt"
32
+ integer_comparison?(actual, expected) && actual < expected
33
+ when "lte"
34
+ integer_comparison?(actual, expected) && actual <= expected
35
+ when "gt"
36
+ integer_comparison?(actual, expected) && actual > expected
37
+ when "gte"
38
+ integer_comparison?(actual, expected) && actual >= expected
39
+ when "in"
40
+ expected.any? do |candidate|
41
+ same_scalar_type?(actual, candidate) && actual == candidate
42
+ end
43
+ else
44
+ false
45
+ end
46
+ end
47
+
48
+ def canonical_constraint?(constraint)
49
+ return false unless constraint.is_a?(Hash)
50
+ return false unless constraint.keys.all?(String)
51
+ return false unless constraint.keys.sort == CONSTRAINT_KEYS
52
+
53
+ field = constraint["field"]
54
+ return false unless field.is_a?(String) && !field.empty?
55
+
56
+ value = constraint["value"]
57
+
58
+ case constraint["operator"]
59
+ when "eq"
60
+ scalar_value?(value)
61
+ when "lt", "lte", "gt", "gte"
62
+ value.is_a?(Integer)
63
+ when "in"
64
+ value.is_a?(Array) && value.all? { |element| scalar_value?(element) }
65
+ else
66
+ false
67
+ end
68
+ end
69
+
70
+ def integer_comparison?(actual, expected)
71
+ actual.is_a?(Integer) && expected.is_a?(Integer)
72
+ end
73
+
74
+ def same_scalar_type?(actual, expected)
75
+ return actual.is_a?(String) if expected.is_a?(String)
76
+ return actual.is_a?(Integer) if expected.is_a?(Integer)
77
+ return boolean_value?(actual) if boolean_value?(expected)
78
+
79
+ false
80
+ end
81
+
82
+ def scalar_value?(value)
83
+ value.is_a?(String) || value.is_a?(Integer) || boolean_value?(value)
84
+ end
85
+
86
+ def boolean_value?(value)
87
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,121 @@
1
+ module ActingFor
2
+ module Internal
3
+ class DelegationCreator
4
+ class << self
5
+ def call(agent:, principal:, action:, resource:, constraints:, effect:, expires_at:)
6
+ unless agent.is_a?(ActingFor::Agent) && agent.persisted?
7
+ raise InvalidRequestError, "agent must be a persisted ActingFor::Agent"
8
+ end
9
+ unless principal.is_a?(ActiveRecord::Base) && principal.persisted?
10
+ raise InvalidRequestError, "principal must be a persisted ActiveRecord record"
11
+ end
12
+
13
+ action = string_value(action, "action")
14
+ raise InvalidRequestError, "action must not be blank" if action.blank?
15
+
16
+ resource_type, resource_id = resource_identity(resource)
17
+ effect = string_value(effect, "effect")
18
+ unless %w[allow require_approval].include?(effect)
19
+ raise InvalidRequestError, "effect must be allow or require_approval"
20
+ end
21
+
22
+ constraints = canonical_constraints(constraints)
23
+ validate_expiration(expires_at)
24
+
25
+ ActingFor::Delegation.create!(
26
+ agent: agent, principal: principal, action: action,
27
+ resource_type: resource_type, resource_id: resource_id,
28
+ constraints: constraints, effect: effect,
29
+ expires_at: expires_at, revoked_at: nil
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ def string_value(value, name)
36
+ return value if value.is_a?(String)
37
+ return value.to_s if value.is_a?(Symbol)
38
+
39
+ raise InvalidRequestError, "#{name} must be a String or Symbol"
40
+ end
41
+
42
+ def resource_identity(resource)
43
+ return [nil, nil] if resource.nil?
44
+
45
+ klass = resource.is_a?(Class) ? resource : resource.class
46
+ raise InvalidRequestError, "resource class must provide model_name" unless klass.respond_to?(:model_name)
47
+
48
+ model_name = klass.model_name
49
+ raise InvalidRequestError, "resource model_name must provide name" unless model_name.respond_to?(:name)
50
+
51
+ resource_type = model_name.name
52
+ return [resource_type, nil] if resource.is_a?(Class)
53
+
54
+ raise InvalidRequestError, "resource instance must provide id" unless resource.respond_to?(:id)
55
+
56
+ id = resource.id
57
+ raise InvalidRequestError, "resource id must not be nil" if id.nil?
58
+
59
+ resource_id = id.to_s
60
+ raise InvalidRequestError, "resource id must not be empty" if resource_id.empty?
61
+
62
+ [resource_type, resource_id]
63
+ end
64
+
65
+ def canonical_constraints(constraints)
66
+ raise InvalidRequestError, "constraints must be an Array" unless constraints.is_a?(Array)
67
+
68
+ constraints.map.with_index do |constraint, index|
69
+ name = "constraints[#{index}]"
70
+ raise InvalidRequestError, "#{name} must be a Hash" unless constraint.is_a?(Hash)
71
+
72
+ canonical = {}
73
+ constraint.each do |key, value|
74
+ key = string_value(key, "#{name} key")
75
+ raise InvalidRequestError, "#{name} has duplicate key #{key}" if canonical.key?(key)
76
+
77
+ canonical[key] = value
78
+ end
79
+ unless canonical.keys.sort == %w[field operator value]
80
+ raise InvalidRequestError, "#{name} must have exactly field, operator and value keys"
81
+ end
82
+
83
+ field = string_value(canonical["field"], "#{name} field")
84
+ raise InvalidRequestError, "#{name} field must not be empty" if field.empty?
85
+
86
+ operator = string_value(canonical["operator"], "#{name} operator")
87
+ value = canonical["value"]
88
+ valid_value = case operator
89
+ when "eq"
90
+ scalar_value?(value)
91
+ when "lt", "lte", "gt", "gte"
92
+ value.is_a?(Integer)
93
+ when "in"
94
+ value.is_a?(Array) && value.all? { |element| scalar_value?(element) }
95
+ else
96
+ raise InvalidRequestError, "#{name} operator must be eq, lt, lte, gt, gte or in"
97
+ end
98
+ raise InvalidRequestError, "#{name} value has an invalid type for #{operator}" unless valid_value
99
+
100
+ { "field" => field, "operator" => operator, "value" => operator == "in" ? value.dup : value }
101
+ end
102
+ end
103
+
104
+ def scalar_value?(value)
105
+ value.is_a?(String) || value.is_a?(Integer) || value.is_a?(TrueClass) || value.is_a?(FalseClass)
106
+ end
107
+
108
+ def validate_expiration(expires_at)
109
+ return if expires_at.nil?
110
+
111
+ unless expires_at.is_a?(Time) || expires_at.is_a?(ActiveSupport::TimeWithZone)
112
+ raise InvalidRequestError, "expires_at must be a Time or ActiveSupport::TimeWithZone"
113
+ end
114
+ return if expires_at > ActingFor.current_time
115
+
116
+ raise InvalidRequestError, "expires_at must be in the future"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,11 @@
1
+ class CreateActingForAgents < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :acting_for_agents do |t|
4
+ t.string :identifier, null: false
5
+ t.string :name
6
+ t.timestamps null: false
7
+ end
8
+
9
+ add_index :acting_for_agents, :identifier, unique: true
10
+ end
11
+ end
@@ -0,0 +1,30 @@
1
+ class CreateActingForDelegations < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :acting_for_delegations do |t|
4
+ t.bigint :agent_id, null: false
5
+ t.string :principal_type, null: false
6
+ t.string :principal_id, null: false
7
+ t.string :action, null: false
8
+ t.string :resource_type
9
+ t.string :resource_id
10
+ t.string :effect, null: false
11
+ t.json :constraints, null: false, default: []
12
+ t.datetime :expires_at
13
+ t.datetime :revoked_at
14
+ t.timestamps null: false
15
+ end
16
+
17
+ add_foreign_key :acting_for_delegations, :acting_for_agents, column: :agent_id
18
+ add_index :acting_for_delegations, :agent_id
19
+ add_index :acting_for_delegations,
20
+ %i[agent_id principal_type principal_id action resource_type],
21
+ name: "idx_acting_for_delegations_authorization_lookup"
22
+
23
+ add_check_constraint :acting_for_delegations,
24
+ "resource_type IS NOT NULL OR resource_id IS NULL",
25
+ name: "chk_acting_for_delegations_resource_scope"
26
+ add_check_constraint :acting_for_delegations,
27
+ "effect IN ('allow', 'require_approval')",
28
+ name: "chk_acting_for_delegations_effect"
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ class CreateActingForAuditEvents < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :acting_for_audit_events do |t|
4
+ t.bigint :agent_id, null: false
5
+ t.string :agent_identifier, null: false
6
+ t.string :principal_type, null: false
7
+ t.string :principal_id, null: false
8
+ t.string :action, null: false
9
+ t.string :resource_type
10
+ t.string :resource_id
11
+ t.string :decision, null: false
12
+ t.string :reason_code, null: false
13
+ t.json :matched_delegation_ids, null: false, default: []
14
+ t.json :sanitized_context, null: false, default: {}
15
+ t.datetime :created_at, null: false
16
+ end
17
+
18
+ add_check_constraint :acting_for_audit_events,
19
+ "resource_type IS NOT NULL OR resource_id IS NULL",
20
+ name: "chk_acting_for_audit_events_resource_scope"
21
+ add_check_constraint :acting_for_audit_events,
22
+ "decision IN ('allow', 'deny', 'require_approval')",
23
+ name: "chk_acting_for_audit_events_decision"
24
+ add_check_constraint :acting_for_audit_events,
25
+ "reason_code IN ('delegation_allowed', 'delegation_requires_approval', " \
26
+ "'no_matching_delegation')",
27
+ name: "chk_acting_for_audit_events_reason_code"
28
+ end
29
+ end
@@ -0,0 +1,27 @@
1
+ module ActingFor
2
+ class Decision
3
+ STATUSES = %i[allow deny require_approval].freeze
4
+ private_constant :STATUSES
5
+
6
+ attr_reader :status
7
+
8
+ def initialize(status)
9
+ raise ArgumentError, "invalid decision status" unless STATUSES.include?(status)
10
+
11
+ @status = status
12
+ freeze
13
+ end
14
+
15
+ def allowed?
16
+ status == :allow
17
+ end
18
+
19
+ def denied?
20
+ status == :deny
21
+ end
22
+
23
+ def approval_required?
24
+ status == :require_approval
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,7 @@
1
+ require "rails"
2
+
3
+ module ActingFor
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace ActingFor
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ module ActingFor
2
+ class Error < StandardError
3
+ end
4
+
5
+ class InvalidRequestError < Error
6
+ end
7
+
8
+ class InternalError < Error
9
+ end
10
+
11
+ class AuditPersistenceError < InternalError
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ module ActingFor
2
+ VERSION = "0.1.0"
3
+ end
data/lib/acting_for.rb ADDED
@@ -0,0 +1,24 @@
1
+ require "acting_for/version"
2
+ require "acting_for/engine"
3
+ require "acting_for/errors"
4
+ require "acting_for/decision"
5
+
6
+ module ActingFor
7
+ def self.authorize(agent:, principal:, action:, resource: nil, context: {}, audit_context_keys: [])
8
+ Internal::Authorization.call(
9
+ agent: agent, principal: principal, action: action, resource: resource, context: context,
10
+ audit_context_keys: audit_context_keys
11
+ )
12
+ end
13
+
14
+ def self.delegate(agent:, principal:, action:, resource: nil, constraints: [], effect:, expires_at: nil)
15
+ Internal::DelegationCreator.call(
16
+ agent: agent, principal: principal, action: action, resource: resource,
17
+ constraints: constraints, effect: effect, expires_at: expires_at
18
+ )
19
+ end
20
+
21
+ def self.current_time
22
+ Time.current
23
+ end
24
+ end
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: acting_for
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ChangQuan Cui
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rubocop
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.91'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.91'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activerecord
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '8.2'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '8.0'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '8.2'
46
+ - !ruby/object:Gem::Dependency
47
+ name: activesupport
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '8.0'
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '8.2'
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '8.0'
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '8.2'
66
+ - !ruby/object:Gem::Dependency
67
+ name: railties
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '8.0'
73
+ - - "<"
74
+ - !ruby/object:Gem::Version
75
+ version: '8.2'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '8.0'
83
+ - - "<"
84
+ - !ruby/object:Gem::Version
85
+ version: '8.2'
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - LICENSE
91
+ - README.md
92
+ - app/models/acting_for/agent.rb
93
+ - app/models/acting_for/application_record.rb
94
+ - app/models/acting_for/audit_event.rb
95
+ - app/models/acting_for/delegation.rb
96
+ - app/services/acting_for/internal/authorization.rb
97
+ - app/services/acting_for/internal/constraint_evaluator.rb
98
+ - app/services/acting_for/internal/delegation_creator.rb
99
+ - db/migrate/20260918030001_create_acting_for_agents.rb
100
+ - db/migrate/20260918030002_create_acting_for_delegations.rb
101
+ - db/migrate/20260918030003_create_acting_for_audit_events.rb
102
+ - lib/acting_for.rb
103
+ - lib/acting_for/decision.rb
104
+ - lib/acting_for/engine.rb
105
+ - lib/acting_for/errors.rb
106
+ - lib/acting_for/version.rb
107
+ homepage: https://github.com/cuichangquan/acting_for
108
+ licenses:
109
+ - MIT
110
+ metadata:
111
+ source_code_uri: https://github.com/cuichangquan/acting_for
112
+ rdoc_options: []
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '3.4'
120
+ - - "<"
121
+ - !ruby/object:Gem::Version
122
+ version: '4.1'
123
+ required_rubygems_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '0'
128
+ requirements: []
129
+ rubygems_version: 3.6.9
130
+ specification_version: 4
131
+ summary: Rails-native delegated authorization for AI agents.
132
+ test_files: []