agentadmit 1.1.2 → 1.3.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 +4 -4
- data/README.md +62 -30
- data/lib/agentadmit/config.rb +43 -5
- data/lib/agentadmit/introspection_client.rb +163 -40
- data/lib/agentadmit/middleware.rb +12 -7
- data/lib/agentadmit/railtie.rb +8 -0
- data/lib/agentadmit/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74299884ea21ad2cb70261b0d2e7025bf4bc07e330f76bfc44009af8bba3b71b
|
|
4
|
+
data.tar.gz: 846ec2f9b8ed3afe7e9113fa7e1c1bf4b2fd7b074ce0f61e96550347db6c185b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 25964578da91a2be9763398021fad033e9538cf6270c2fe99d66185c4da6d884514bcf9c9cfd9a2f60d017e0aeecdeb62d9d6ce1ef19ebfa543eb274cf2cc688
|
|
7
|
+
data.tar.gz: fdba91838161e85923cca0e4f1c84d72b724ad7bcf8e1be1c248f6af5ab1c1ec2506345e27016e7c8b3a7a989c5c65c3474fa263c23466cc7b100ec06b4263fc
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
User-mediated AI agent authorization. Plug-and-play for any Rails app.
|
|
4
4
|
|
|
5
|
-
> **Get started:** Sign up at [agentadmit.com](https://agentadmit.com)
|
|
5
|
+
> **Get started:** Sign up at [agentadmit.com](https://agentadmit.com) -- Get your test keys -- Install the SDK -- Build.
|
|
6
6
|
> Test keys are available immediately after signup. Live keys become available when you subscribe an app.
|
|
7
7
|
|
|
8
8
|
## Quick Start
|
|
@@ -14,7 +14,6 @@ gem 'agentadmit'
|
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
bundle install
|
|
17
|
-
rails generate agentadmit:install
|
|
18
17
|
```
|
|
19
18
|
|
|
20
19
|
Add your credentials to `config/credentials.yml.enc` or `.env`:
|
|
@@ -24,6 +23,14 @@ AGENTADMIT_APP_ID=app_yourappid
|
|
|
24
23
|
AGENTADMIT_API_KEY=aa_test_yourkey
|
|
25
24
|
```
|
|
26
25
|
|
|
26
|
+
Create an initializer at `config/initializers/agentadmit.rb`:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
AgentAdmit.configure do |config|
|
|
30
|
+
# Defaults are read from ENV - nothing required here unless you need overrides.
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
27
34
|
Add scope enforcement to any controller:
|
|
28
35
|
|
|
29
36
|
```ruby
|
|
@@ -36,13 +43,16 @@ class OrdersController < ApplicationController
|
|
|
36
43
|
end
|
|
37
44
|
```
|
|
38
45
|
|
|
46
|
+
The `require_scope_if_agent!` method is available in all controllers automatically
|
|
47
|
+
when you use Rails -- the Railtie includes `AgentAdmit::ScopeEnforcement` into
|
|
48
|
+
`ActionController::Base` on load.
|
|
49
|
+
|
|
39
50
|
Your app now supports AI agent connections with:
|
|
40
51
|
- Scoped access control (you define the scopes)
|
|
41
52
|
- User-controlled connection duration
|
|
42
53
|
- Token generation and exchange
|
|
43
54
|
- Mandatory introspection (every agent request validated through AgentAdmit)
|
|
44
|
-
- Revocation
|
|
45
|
-
- Discovery endpoint at `/.well-known/agentadmit`
|
|
55
|
+
- Revocation support via `tokens.revoke`
|
|
46
56
|
|
|
47
57
|
## How It Works
|
|
48
58
|
|
|
@@ -58,27 +68,49 @@ The token goes to the human, not the agent. No automated delivery = no prompt in
|
|
|
58
68
|
|
|
59
69
|
**Mandatory introspection.** All token validation goes through api.agentadmit.com. There is no self-hosted mode. No local JWT validation. No bypass. This is required for security, audit logging, and scope enforcement.
|
|
60
70
|
|
|
61
|
-
**Admin revocation.** As the app operator, you can revoke any user's agent connection via `DELETE /agentadmit/admin/connections/{connection_id}` (requires admin role or `manage:connections` scope).
|
|
62
|
-
|
|
63
71
|
**Embeddable admin panel.** Drop the `<AgentAdmitAdminPanel>` React component into your admin section to view all agent connections, usage metrics, billing status, and revoke any connection without leaving your app. See the React SDK for details.
|
|
64
72
|
|
|
65
73
|
**In-app AI scopes.** If your app has built-in AI features (analysis, plan generation, photo recognition), do not expose those as agent scopes. The user's AI agent can read the raw data and do the analysis itself. Exposing in-app AI endpoints to agents creates double cost.
|
|
66
74
|
|
|
75
|
+
### Consent Ledger (Caller-Identity Consent)
|
|
76
|
+
|
|
77
|
+
AgentAdmit can host per-user consent switches for three independent caller classes: `human_session`, `in_app_ai`, and `external_agent`. No class's setting implies another's.
|
|
78
|
+
|
|
79
|
+
**External agents:** the verify result already carries the verdict:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
result = AgentAdmit::IntrospectionClient.new.verify(token)
|
|
83
|
+
unless result.consent_granted?
|
|
84
|
+
# the data owner has switched external agents off: return your own 403
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Human sessions and in-app AI** never hold AgentAdmit tokens, so ask directly:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
verdict = AgentAdmit::IntrospectionClient.new.check_consent(
|
|
92
|
+
app_user_id: "user_8842", caller_class: "in_app_ai"
|
|
93
|
+
)
|
|
94
|
+
head :forbidden unless verdict["granted"]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Consent is orthogonal to revocation: a denied verdict means your app returns its own 403; the connection and token stay valid so the user can flip consent back on without re-connecting. Write switches through `PUT /api/v1/consent/settings` from your backend; export the audit trail with `GET /api/v1/consent/export` (every plan).
|
|
98
|
+
|
|
67
99
|
## Rate Limiting
|
|
68
100
|
|
|
69
|
-
The AgentAdmit introspection endpoint enforces rate limits. The Ruby SDK handles HTTP 429 responses **automatically** with exponential backoff and jitter
|
|
101
|
+
The AgentAdmit introspection endpoint enforces rate limits. The Ruby SDK handles HTTP 429 responses **automatically** with exponential backoff and jitter -- no changes needed in your middleware code.
|
|
70
102
|
|
|
71
103
|
### Retry behavior
|
|
72
104
|
|
|
73
105
|
| Parameter | Default | Description |
|
|
74
106
|
|-----------|---------|-------------|
|
|
75
107
|
| Initial delay | 1 second | First retry wait |
|
|
76
|
-
| Backoff multiplier |
|
|
108
|
+
| Backoff multiplier | 2x | Doubles each retry |
|
|
77
109
|
| Cap | 30 seconds | Maximum wait per retry |
|
|
78
|
-
| Jitter | 0
|
|
110
|
+
| Jitter | 0-500 ms | Random addition to each delay |
|
|
79
111
|
| Max retries | **3** | Configurable |
|
|
80
112
|
|
|
81
|
-
The SDK also respects the `Retry-After` response header
|
|
113
|
+
The SDK also respects the `Retry-After` response header -- if present, it overrides the computed backoff delay.
|
|
82
114
|
|
|
83
115
|
### Configuring max retries
|
|
84
116
|
|
|
@@ -107,10 +139,10 @@ end
|
|
|
107
139
|
```
|
|
108
140
|
|
|
109
141
|
`RateLimitError` attributes:
|
|
110
|
-
- `retry_after`
|
|
111
|
-
- `limit`
|
|
112
|
-
- `remaining`
|
|
113
|
-
- `reset`
|
|
142
|
+
- `retry_after` -- seconds from `Retry-After` header (`nil` if absent)
|
|
143
|
+
- `limit` -- `X-RateLimit-Limit` header value (`nil` if absent)
|
|
144
|
+
- `remaining` -- `X-RateLimit-Remaining` header value (`nil` if absent)
|
|
145
|
+
- `reset` -- `X-RateLimit-Reset` Unix timestamp (`nil` if absent)
|
|
114
146
|
|
|
115
147
|
## Documentation
|
|
116
148
|
|
|
@@ -122,14 +154,14 @@ Full integration guide: https://agentadmit.com/docs/app-owner-guide
|
|
|
122
154
|
The AgentAdmit Ruby SDK runs server-side and does not interact with app stores or end-user devices directly.
|
|
123
155
|
|
|
124
156
|
### What the SDK does
|
|
125
|
-
- Validates AgentAdmit tokens by calling AgentAdmit's hosted introspection endpoint (`https://api.agentadmit.com/api/v1/verify`) on every agent request
|
|
157
|
+
- Validates AgentAdmit tokens by calling AgentAdmit's hosted introspection endpoint (`https://api.agentadmit.com/api/v1/verify`) on every agent request -- this is mandatory introspection; there is no local or offline validation mode
|
|
126
158
|
- Enforces scope-based access control on your API routes
|
|
127
|
-
- Manages connection lifecycle (create, revoke
|
|
159
|
+
- Manages connection lifecycle (create, revoke) using the AgentAdmit hosted service
|
|
128
160
|
|
|
129
161
|
### What the SDK does NOT do
|
|
130
|
-
- Does not transmit raw end-user PII (such as name, email, or device identifiers)
|
|
131
|
-
- Does not perform passive background telemetry or analytics
|
|
132
|
-
- Does not maintain its own persistent
|
|
162
|
+
- Does not transmit raw end-user PII (such as name, email, or device identifiers) -- each introspection request sends the opaque access token and your API key
|
|
163
|
+
- Does not perform passive background telemetry or analytics -- network calls occur only during active token validation
|
|
164
|
+
- Does not maintain its own persistent local storage
|
|
133
165
|
|
|
134
166
|
### What the AgentAdmit hosted service records
|
|
135
167
|
On every token validation, AgentAdmit's `/api/v1/verify` endpoint receives the access token and API key, resolves the token to its `user_id`, `connection_id`, granted `scopes`, and `agent_label`, and records per-call metadata (including the endpoint and timestamp) for billing, audit logging, the security alerts engine, and usage metering. This is integral to how AgentAdmit works and applies to both test and live keys. See the "Mandatory introspection" notes above and the [compliance guide](https://agentadmit.com/docs/compliance) for the full data-handling description.
|
|
@@ -177,10 +209,10 @@ config = alerts.get_alert_config(app_id: 'app_abc123')
|
|
|
177
209
|
|
|
178
210
|
### Notifying Your Users
|
|
179
211
|
|
|
180
|
-
AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes connections. **How you notify your own users is up to you.** AgentAdmit provides the data
|
|
212
|
+
AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes connections. **How you notify your own users is up to you.** AgentAdmit provides the data -- you deliver it through your own system (in-app notifications, email, push, etc.).
|
|
181
213
|
|
|
182
|
-
- **Poll alerts**
|
|
183
|
-
- **Webhook delivery**
|
|
214
|
+
- **Poll alerts** -- Use the SDK methods above from your backend to check for new events, then notify users through your existing system.
|
|
215
|
+
- **Webhook delivery** -- Configure a webhook URL in your AgentAdmit dashboard. When an alert fires, AgentAdmit POSTs the payload to your server, signed with your `whsec_...` secret. Always verify the signature against the raw request body before trusting the payload:
|
|
184
216
|
|
|
185
217
|
```ruby
|
|
186
218
|
# Rails controller
|
|
@@ -188,7 +220,7 @@ AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes
|
|
|
188
220
|
AgentAdmit::Webhook.verify_signature(
|
|
189
221
|
request.raw_post,
|
|
190
222
|
request.headers["X-AgentAdmit-Signature"].to_s,
|
|
191
|
-
AgentAdmit.configuration.webhook_secret # whsec_
|
|
223
|
+
AgentAdmit.configuration.webhook_secret # whsec_... from AGENTADMIT_WEBHOOK_SECRET
|
|
192
224
|
)
|
|
193
225
|
event = JSON.parse(request.raw_post)
|
|
194
226
|
# ...
|
|
@@ -198,8 +230,8 @@ AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes
|
|
|
198
230
|
end
|
|
199
231
|
```
|
|
200
232
|
|
|
201
|
-
The header format is `t=<unix_ts>,v1=<hex>`
|
|
202
|
-
- **React SDK**
|
|
233
|
+
The header format is `t=<unix_ts>,v1=<hex>` -- an HMAC-SHA256 of `{t}.{raw_body}` keyed with your signing secret. Verification compares in constant time and rejects timestamps more than 5 minutes off (replay protection).
|
|
234
|
+
- **React SDK** -- Embed the `<AlertsPanel>` component so users can view their own alert history and tighten thresholds.
|
|
203
235
|
|
|
204
236
|
### Issuing & Exchanging Tokens
|
|
205
237
|
|
|
@@ -207,18 +239,18 @@ AgentAdmit detects anomalies, fires alerts, and (with kill switch) auto-revokes
|
|
|
207
239
|
tokens = AgentAdmit::TokensClient.new
|
|
208
240
|
|
|
209
241
|
# Duration is tri-state:
|
|
210
|
-
# omit the argument
|
|
211
|
-
# nil
|
|
212
|
-
# Integer (60
|
|
242
|
+
# omit the argument => AgentAdmit default (30 days)
|
|
243
|
+
# nil => until the user revokes
|
|
244
|
+
# Integer (60-31536000) => explicit seconds
|
|
213
245
|
issued = tokens.issue_token(
|
|
214
246
|
user_id: "user_42",
|
|
215
247
|
scopes: ["read:orders"],
|
|
216
248
|
role: "user",
|
|
217
249
|
duration_seconds: nil # until revoked
|
|
218
250
|
)
|
|
219
|
-
connection_token = issued["token"] # ag_ct_
|
|
251
|
+
connection_token = issued["token"] # ag_ct_...
|
|
220
252
|
|
|
221
|
-
# Agent side
|
|
253
|
+
# Agent side -- no API key needed; the connection token is the credential.
|
|
222
254
|
granted = tokens.exchange(connection_token, agent_label: "MyAssistant")
|
|
223
255
|
|
|
224
256
|
# Revoke when the user disconnects the agent.
|
data/lib/agentadmit/config.rb
CHANGED
|
@@ -5,26 +5,39 @@
|
|
|
5
5
|
# There is no self-hosted mode. No local JWT validation. No bypass.
|
|
6
6
|
# This is required for security, audit logging, and scope enforcement.
|
|
7
7
|
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
8
10
|
module AgentAdmit
|
|
9
11
|
class Config
|
|
10
|
-
attr_accessor :app_id, :api_key, :
|
|
11
|
-
:token_prefix_access, :token_prefix_connection,
|
|
12
|
+
attr_accessor :app_id, :api_key, :token_prefix_access, :token_prefix_connection,
|
|
12
13
|
:webhook_secret, :max_retries
|
|
13
14
|
|
|
15
|
+
attr_reader :verify_url, :api_url
|
|
16
|
+
|
|
14
17
|
def initialize
|
|
15
18
|
@app_id = ENV.fetch("AGENTADMIT_APP_ID", "")
|
|
16
19
|
@api_key = ENV.fetch("AGENTADMIT_API_KEY", "")
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
self.verify_url = ENV.fetch("AGENTADMIT_VERIFY_URL", "https://api.agentadmit.com/api/v1/verify")
|
|
21
|
+
self.api_url = ENV.fetch("AGENTADMIT_API_URL", "https://api.agentadmit.com")
|
|
19
22
|
@token_prefix_access = "ag_at_"
|
|
20
23
|
@token_prefix_connection = "ag_ct_"
|
|
21
|
-
# Webhook signing secret (whsec_
|
|
24
|
+
# Webhook signing secret (whsec_...) -- shown once when you configure the
|
|
22
25
|
# alert webhook URL in the dashboard. Used by AgentAdmit::Webhook.
|
|
23
26
|
@webhook_secret = ENV.fetch("AGENTADMIT_WEBHOOK_SECRET", "")
|
|
24
27
|
# Max retries on HTTP 429 before raising RateLimitError. Default: 3.
|
|
25
28
|
@max_retries = ENV.fetch("AGENTADMIT_MAX_RETRIES", "3").to_i
|
|
26
29
|
end
|
|
27
30
|
|
|
31
|
+
def verify_url=(url)
|
|
32
|
+
validate_url!(url, :verify_url)
|
|
33
|
+
@verify_url = url
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def api_url=(url)
|
|
37
|
+
validate_url!(url, :api_url)
|
|
38
|
+
@api_url = url
|
|
39
|
+
end
|
|
40
|
+
|
|
28
41
|
##
|
|
29
42
|
# Validate the API key prefix (aa_test_/aa_live_) without ever echoing
|
|
30
43
|
# the key itself.
|
|
@@ -37,5 +50,30 @@ module AgentAdmit
|
|
|
37
50
|
|
|
38
51
|
raise ConfigurationError, "api_key must start with 'aa_test_' or 'aa_live_'"
|
|
39
52
|
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Local loopback hostnames / addresses that are allowed over plain HTTP.
|
|
57
|
+
LOCALHOST_HOSTS = %w[localhost 127.0.0.1 [::1]].freeze
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# Raise ConfigurationError for non-https URLs unless the host is localhost.
|
|
61
|
+
#
|
|
62
|
+
def validate_url!(url, field)
|
|
63
|
+
return if url.nil? || url.empty?
|
|
64
|
+
|
|
65
|
+
uri = URI.parse(url)
|
|
66
|
+
return if uri.scheme == "https"
|
|
67
|
+
|
|
68
|
+
if uri.scheme == "http" && LOCALHOST_HOSTS.include?(uri.host)
|
|
69
|
+
return
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
raise ConfigurationError,
|
|
73
|
+
"#{field} must use https (got: #{url.inspect}). " \
|
|
74
|
+
"Plain http is only permitted for localhost / 127.0.0.1 / [::1]."
|
|
75
|
+
rescue URI::InvalidURIError
|
|
76
|
+
raise ConfigurationError, "#{field} is not a valid URL: #{url.inspect}"
|
|
77
|
+
end
|
|
40
78
|
end
|
|
41
79
|
end
|
|
@@ -6,15 +6,34 @@ require "uri"
|
|
|
6
6
|
|
|
7
7
|
module AgentAdmit
|
|
8
8
|
##
|
|
9
|
-
# Mandatory introspection client
|
|
9
|
+
# Mandatory introspection client -- validates tokens via AgentAdmit hosted service.
|
|
10
10
|
# No local JWT decode. Every verification call goes through AgentAdmit.
|
|
11
11
|
#
|
|
12
12
|
class IntrospectionClient
|
|
13
|
+
# Hard cap on any single retry wait -- including a server-supplied Retry-After.
|
|
14
|
+
MAX_RETRY_WAIT_MS = 30_000
|
|
15
|
+
# Hard cap on cumulative wait across all retries of a single verify call.
|
|
16
|
+
MAX_RETRY_BUDGET_MS = 120_000
|
|
17
|
+
|
|
13
18
|
IntrospectionResult = Struct.new(:user_id, :connection_id, :scopes, :agent_label,
|
|
14
|
-
:sub, :role, :app_id, :jti, :exp, keyword_init: true) do
|
|
19
|
+
:sub, :role, :app_id, :jti, :exp, :consent, keyword_init: true) do
|
|
15
20
|
def has_scope?(scope)
|
|
16
21
|
scopes.include?(scope)
|
|
17
22
|
end
|
|
23
|
+
|
|
24
|
+
# Consent Ledger verdict for the external-agent path (additive; may be
|
|
25
|
+
# nil). A denied verdict means the app returns its own 403 -- the token
|
|
26
|
+
# itself stays valid (consent is orthogonal to revocation).
|
|
27
|
+
#
|
|
28
|
+
# Contract (matches the PHP and Java SDKs): an ABSENT consent block
|
|
29
|
+
# means a legacy server that never sends one -- treated as allowed,
|
|
30
|
+
# which is also the platform default for the external-agent class. A
|
|
31
|
+
# PRESENT block grants only on an explicit boolean true; a missing or
|
|
32
|
+
# non-boolean granted value is treated as denied (malformed = deny).
|
|
33
|
+
def consent_granted?
|
|
34
|
+
return true if consent.nil?
|
|
35
|
+
consent["granted"] == true
|
|
36
|
+
end
|
|
18
37
|
end
|
|
19
38
|
|
|
20
39
|
def initialize(config = nil)
|
|
@@ -41,6 +60,7 @@ module AgentAdmit
|
|
|
41
60
|
|
|
42
61
|
max_retries = @config.respond_to?(:max_retries) ? @config.max_retries.to_i : 3
|
|
43
62
|
delay_ms = 1000 # initial backoff in milliseconds
|
|
63
|
+
waited_ms = 0 # cumulative wait across retries
|
|
44
64
|
|
|
45
65
|
uri = URI.parse(@config.verify_url)
|
|
46
66
|
http = build_http(uri)
|
|
@@ -72,11 +92,25 @@ module AgentAdmit
|
|
|
72
92
|
)
|
|
73
93
|
end
|
|
74
94
|
|
|
75
|
-
# Compute wait:
|
|
76
|
-
|
|
95
|
+
# Compute wait: Retry-After beats exponential backoff, but both are
|
|
96
|
+
# capped -- Retry-After is untrusted server input and must not pin
|
|
97
|
+
# the caller.
|
|
98
|
+
requested_ms = retry_after ? (retry_after * 1000).ceil : delay_ms
|
|
99
|
+
wait_ms = [[requested_ms, 0].max, MAX_RETRY_WAIT_MS].min
|
|
77
100
|
jitter_ms = rand(0..500)
|
|
78
101
|
total_ms = wait_ms + jitter_ms
|
|
79
102
|
|
|
103
|
+
if waited_ms + total_ms > MAX_RETRY_BUDGET_MS
|
|
104
|
+
raise RateLimitError.new(
|
|
105
|
+
"AgentAdmit rate limit retry budget (#{MAX_RETRY_BUDGET_MS / 1000}s) exhausted.",
|
|
106
|
+
retry_after: retry_after,
|
|
107
|
+
limit: rl_limit,
|
|
108
|
+
remaining: rl_remaining,
|
|
109
|
+
reset: rl_reset
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
waited_ms += total_ms
|
|
113
|
+
|
|
80
114
|
warn "[AgentAdmit] Rate-limited (attempt #{attempt + 1}/#{max_retries}). " \
|
|
81
115
|
"Retrying in #{total_ms}ms."
|
|
82
116
|
|
|
@@ -85,54 +119,143 @@ module AgentAdmit
|
|
|
85
119
|
next
|
|
86
120
|
end
|
|
87
121
|
|
|
88
|
-
# Non-429
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
# Check active flag (RFC 7662 introspection pattern).
|
|
94
|
-
# The verify endpoint returns {active: false} with HTTP 200 for
|
|
95
|
-
# invalid/expired/revoked tokens; the error code is one of
|
|
96
|
-
# VERIFY_ERROR_CODES (e.g. token_expired, connection_expired,
|
|
97
|
-
# environment_mismatch). Without this check, we'd read empty scopes.
|
|
98
|
-
unless data["active"]
|
|
99
|
-
reason = data["error"] || "invalid_token"
|
|
100
|
-
raise InvalidTokenError.new("Token is not active: #{reason}", code: reason)
|
|
122
|
+
# Non-429: only treat 2xx as a candidate for a valid token.
|
|
123
|
+
unless (200..299).cover?(status)
|
|
124
|
+
if status == 401
|
|
125
|
+
data = JSON.parse(response.body) rescue {}
|
|
126
|
+
raise InvalidTokenError, data["error_description"] || "Token validation failed"
|
|
101
127
|
end
|
|
128
|
+
raise IntrospectionError, "Verification service returned #{response.code}"
|
|
129
|
+
end
|
|
102
130
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
131
|
+
# 2xx -- parse and strictly validate the response body.
|
|
132
|
+
data = begin
|
|
133
|
+
JSON.parse(response.body)
|
|
134
|
+
rescue JSON::ParserError
|
|
135
|
+
raise IntrospectionError, "Introspection response is not valid JSON"
|
|
136
|
+
end
|
|
108
137
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
connection_id: data["connection_id"],
|
|
114
|
-
scopes: data["scopes"] || [],
|
|
115
|
-
agent_label: data["agent_label"] || "Unknown Agent",
|
|
116
|
-
sub: data["sub"],
|
|
117
|
-
role: data["role"],
|
|
118
|
-
app_id: data["app_id"],
|
|
119
|
-
jti: data["jti"],
|
|
120
|
-
exp: data["exp"]
|
|
121
|
-
)
|
|
122
|
-
when 401
|
|
123
|
-
data = JSON.parse(response.body) rescue {}
|
|
124
|
-
raise InvalidTokenError, data["error_description"] || "Token validation failed"
|
|
125
|
-
else
|
|
126
|
-
raise IntrospectionError, "Verification service returned #{response.code}"
|
|
138
|
+
# active must be strictly true (boolean).
|
|
139
|
+
unless data["active"] == true
|
|
140
|
+
reason = data["error"] || "invalid_token"
|
|
141
|
+
raise InvalidTokenError.new("Token is not active: #{reason}", code: reason)
|
|
127
142
|
end
|
|
143
|
+
|
|
144
|
+
# insufficient_scope arrives with active: true (token valid,
|
|
145
|
+
# requested scope not granted).
|
|
146
|
+
if data["error"] == "insufficient_scope"
|
|
147
|
+
raise InsufficientScopeError, data["error_description"] || "Scope not granted"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Validate that consumed fields have the expected types when present.
|
|
151
|
+
validate_introspection_types!(data)
|
|
152
|
+
|
|
153
|
+
# Keep any PRESENT consent hash, even with a malformed granted value:
|
|
154
|
+
# coercing it to nil would read as "legacy server, allowed" in
|
|
155
|
+
# consent_granted?, silently failing open. A malformed verdict must
|
|
156
|
+
# stay visible so consent_granted? can deny it.
|
|
157
|
+
consent = data["consent"]
|
|
158
|
+
consent = nil unless consent.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
return IntrospectionResult.new(
|
|
161
|
+
user_id: data["user_id"],
|
|
162
|
+
connection_id: data["connection_id"],
|
|
163
|
+
scopes: data["scopes"] || [],
|
|
164
|
+
agent_label: data["agent_label"] || "Unknown Agent",
|
|
165
|
+
sub: data["sub"],
|
|
166
|
+
role: data["role"],
|
|
167
|
+
app_id: data["app_id"],
|
|
168
|
+
jti: data["jti"],
|
|
169
|
+
exp: data["exp"],
|
|
170
|
+
consent: consent
|
|
171
|
+
)
|
|
128
172
|
end
|
|
129
173
|
|
|
130
174
|
# Should never be reached
|
|
131
175
|
raise IntrospectionError, "Unexpected exit from retry loop"
|
|
132
176
|
end
|
|
133
177
|
|
|
178
|
+
CALLER_CLASSES = %w[human_session in_app_ai external_agent].freeze
|
|
179
|
+
|
|
180
|
+
##
|
|
181
|
+
# Ask the Consent Ledger whether a caller class may act on a user's data.
|
|
182
|
+
# Decision point for the token-less caller classes (human_session,
|
|
183
|
+
# in_app_ai); external agents get the same verdict on the verify result.
|
|
184
|
+
#
|
|
185
|
+
# @param app_user_id [String] your app's identifier for the data owner
|
|
186
|
+
# @param caller_class [String] "human_session" | "in_app_ai" | "external_agent"
|
|
187
|
+
# @param scope_group [String, nil] optional finer-than-class group
|
|
188
|
+
# @return [Hash] verdict: granted, caller_class, scope_group, source, evaluated_at
|
|
189
|
+
# @raise [ArgumentError] unknown caller_class
|
|
190
|
+
# @raise [IntrospectionError] hosted service unreachable or rejected the call
|
|
191
|
+
#
|
|
192
|
+
def check_consent(app_user_id:, caller_class:, scope_group: nil)
|
|
193
|
+
unless CALLER_CLASSES.include?(caller_class)
|
|
194
|
+
raise ArgumentError, "caller_class must be one of #{CALLER_CLASSES.join(', ')}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
uri = URI.parse("#{@config.api_url.sub(%r{/\z}, '')}/api/v1/consent/check")
|
|
198
|
+
http = build_http(uri)
|
|
199
|
+
|
|
200
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
201
|
+
request["Authorization"] = "Bearer #{@config.api_key}"
|
|
202
|
+
request["Content-Type"] = "application/json"
|
|
203
|
+
body = { app_user_id: app_user_id, caller_class: caller_class }
|
|
204
|
+
body[:scope_group] = scope_group if scope_group
|
|
205
|
+
request.body = JSON.generate(body)
|
|
206
|
+
|
|
207
|
+
response = begin
|
|
208
|
+
http.request(request)
|
|
209
|
+
rescue StandardError => e
|
|
210
|
+
raise IntrospectionError, "Consent check failed: #{e.message}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
unless (200..299).cover?(response.code.to_i)
|
|
214
|
+
data = JSON.parse(response.body) rescue {}
|
|
215
|
+
raise IntrospectionError,
|
|
216
|
+
data["error_description"] || data["error"] || "Consent check returned #{response.code}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# 2xx -- parse strictly; a garbage body must not read as an empty verdict.
|
|
220
|
+
begin
|
|
221
|
+
JSON.parse(response.body)
|
|
222
|
+
rescue JSON::ParserError
|
|
223
|
+
raise IntrospectionError, "Consent check response is not valid JSON"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
134
227
|
private
|
|
135
228
|
|
|
229
|
+
##
|
|
230
|
+
# Enforce that the fields the middleware relies on have the correct types.
|
|
231
|
+
# Any type mismatch means we cannot safely use the response -- treat as invalid.
|
|
232
|
+
#
|
|
233
|
+
# @raise [InvalidTokenError]
|
|
234
|
+
#
|
|
235
|
+
def validate_introspection_types!(data)
|
|
236
|
+
# user_id is required and must be a String.
|
|
237
|
+
unless data["user_id"].is_a?(String)
|
|
238
|
+
raise InvalidTokenError, "Introspection returned no user"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# agent_id, connection_id -- must be String when present.
|
|
242
|
+
%w[agent_id connection_id].each do |field|
|
|
243
|
+
val = data[field]
|
|
244
|
+
next if val.nil?
|
|
245
|
+
unless val.is_a?(String)
|
|
246
|
+
raise InvalidTokenError, "Introspection field '#{field}' must be a String"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# scopes -- must be Array of Strings when present.
|
|
251
|
+
if data.key?("scopes")
|
|
252
|
+
scopes = data["scopes"]
|
|
253
|
+
unless scopes.is_a?(Array) && scopes.all? { |s| s.is_a?(String) }
|
|
254
|
+
raise InvalidTokenError, "Introspection field 'scopes' must be an Array of Strings"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
136
259
|
def build_http(uri)
|
|
137
260
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
138
261
|
http.use_ssl = uri.scheme == "https"
|
|
@@ -6,13 +6,17 @@ module AgentAdmit
|
|
|
6
6
|
# and validates them via introspection.
|
|
7
7
|
#
|
|
8
8
|
# Sets env variables for downstream use:
|
|
9
|
-
# env['agentadmit.auth_type']
|
|
10
|
-
# env['agentadmit.user_id']
|
|
11
|
-
# env['agentadmit.scopes']
|
|
12
|
-
# env['agentadmit.connection_id']
|
|
13
|
-
# env['agentadmit.agent_label']
|
|
9
|
+
# env['agentadmit.auth_type'] -- "agent" or nil
|
|
10
|
+
# env['agentadmit.user_id'] -- validated user ID
|
|
11
|
+
# env['agentadmit.scopes'] -- granted scopes array
|
|
12
|
+
# env['agentadmit.connection_id'] -- connection identifier
|
|
13
|
+
# env['agentadmit.agent_label'] -- agent display name
|
|
14
14
|
#
|
|
15
15
|
class Middleware
|
|
16
|
+
# RFC 7235: the auth-scheme token is case-insensitive.
|
|
17
|
+
# Match "bearer", "Bearer", "BEARER", etc. followed by the ag_at_ prefix.
|
|
18
|
+
BEARER_AGENT_RE = /\Abearer ag_at_/i
|
|
19
|
+
|
|
16
20
|
def initialize(app)
|
|
17
21
|
@app = app
|
|
18
22
|
@client = IntrospectionClient.new
|
|
@@ -22,8 +26,9 @@ module AgentAdmit
|
|
|
22
26
|
def call(env)
|
|
23
27
|
auth = env["HTTP_AUTHORIZATION"] || ""
|
|
24
28
|
|
|
25
|
-
if
|
|
26
|
-
|
|
29
|
+
if BEARER_AGENT_RE.match?(auth)
|
|
30
|
+
# Strip the scheme prefix (case-insensitively) to get the bare token.
|
|
31
|
+
token = auth.sub(/\Abearer /i, "")
|
|
27
32
|
|
|
28
33
|
begin
|
|
29
34
|
result = @client.verify(token)
|
data/lib/agentadmit/railtie.rb
CHANGED
|
@@ -4,6 +4,7 @@ module AgentAdmit
|
|
|
4
4
|
class Railtie < Rails::Railtie
|
|
5
5
|
initializer "agentadmit.middleware" do |app|
|
|
6
6
|
app.middleware.use AgentAdmit::Middleware
|
|
7
|
+
Rails.logger.info "[AgentAdmit] Middleware and scope enforcement active."
|
|
7
8
|
end
|
|
8
9
|
|
|
9
10
|
initializer "agentadmit.configure" do
|
|
@@ -11,5 +12,12 @@ module AgentAdmit
|
|
|
11
12
|
# Config loaded from environment variables by default
|
|
12
13
|
end
|
|
13
14
|
end
|
|
15
|
+
|
|
16
|
+
# Include ScopeEnforcement into every ActionController so that
|
|
17
|
+
# require_scope_if_agent! and require_scope! are available without
|
|
18
|
+
# an explicit `include` in each controller.
|
|
19
|
+
ActiveSupport.on_load(:action_controller) do
|
|
20
|
+
include AgentAdmit::ScopeEnforcement
|
|
21
|
+
end
|
|
14
22
|
end
|
|
15
23
|
end
|
data/lib/agentadmit/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: agentadmit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Christopher Emerson
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|