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 +7 -0
- data/LICENSE +21 -0
- data/README.md +475 -0
- data/app/models/acting_for/agent.rb +29 -0
- data/app/models/acting_for/application_record.rb +5 -0
- data/app/models/acting_for/audit_event.rb +50 -0
- data/app/models/acting_for/delegation.rb +74 -0
- data/app/services/acting_for/internal/authorization.rb +175 -0
- data/app/services/acting_for/internal/constraint_evaluator.rb +92 -0
- data/app/services/acting_for/internal/delegation_creator.rb +121 -0
- data/db/migrate/20260918030001_create_acting_for_agents.rb +11 -0
- data/db/migrate/20260918030002_create_acting_for_delegations.rb +30 -0
- data/db/migrate/20260918030003_create_acting_for_audit_events.rb +29 -0
- data/lib/acting_for/decision.rb +27 -0
- data/lib/acting_for/engine.rb +7 -0
- data/lib/acting_for/errors.rb +13 -0
- data/lib/acting_for/version.rb +3 -0
- data/lib/acting_for.rb +24 -0
- metadata +132 -0
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,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
|
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: []
|