agentadmit 1.0.0 → 1.1.2
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 +90 -6
- data/lib/agentadmit/alerts_client.rb +183 -0
- data/lib/agentadmit/config.rb +18 -2
- data/lib/agentadmit/introspection_client.rb +20 -5
- data/lib/agentadmit/tokens_client.rb +118 -0
- data/lib/agentadmit/version.rb +1 -1
- data/lib/agentadmit/webhook.rb +111 -0
- data/lib/agentadmit.rb +48 -6
- metadata +10 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8231757da1987653f73ed2c9b1cfa48c8745c2220856d99da8427fc017406616
|
|
4
|
+
data.tar.gz: a0128eb51759e86da018e9852db33cafaf853b86fb9f7cd3f15f6aabd06009e6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5eb0d7b249d27e9f5455eabc5b3214493dcd82b6be6563530ec05c66669aef52ae164aba352a21c744024be2b2b7a080fa5581bafff3ff9a1dc4aecab76f78a1
|
|
7
|
+
data.tar.gz: 8f955a6ac7d15948da7bf679e7fab80d5d0576110c672ba1e179f9e5458d6e1a6ef12dc60325d121dc41ccecdb69d0d709c3bbc8c125c3bff5bc6780c6601c84
|
data/README.md
CHANGED
|
@@ -122,15 +122,17 @@ Full integration guide: https://agentadmit.com/docs/app-owner-guide
|
|
|
122
122
|
The AgentAdmit Ruby SDK runs server-side and does not interact with app stores or end-user devices directly.
|
|
123
123
|
|
|
124
124
|
### What the SDK does
|
|
125
|
-
- Validates AgentAdmit tokens
|
|
125
|
+
- 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
126
|
- Enforces scope-based access control on your API routes
|
|
127
|
-
- Manages connection lifecycle (create, revoke, audit)
|
|
127
|
+
- Manages connection lifecycle (create, revoke, audit) using your configured storage backend
|
|
128
128
|
|
|
129
129
|
### What the SDK does NOT do
|
|
130
|
-
- Does not
|
|
131
|
-
- Does not
|
|
132
|
-
- Does not
|
|
133
|
-
|
|
130
|
+
- 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
|
|
131
|
+
- Does not perform passive background telemetry or analytics — network calls occur only during active token validation
|
|
132
|
+
- Does not maintain its own persistent storage — local state (connections, audit log) lives in the storage backend you configure
|
|
133
|
+
|
|
134
|
+
### What the AgentAdmit hosted service records
|
|
135
|
+
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.
|
|
134
136
|
|
|
135
137
|
### Privacy impact
|
|
136
138
|
Since this SDK runs on your server, it has no direct App Store or Play Store compliance surface. Your client-side integration (e.g., the AgentAdmit React SDK) handles privacy manifest and data safety requirements.
|
|
@@ -140,3 +142,85 @@ For complete compliance guidance, see our [compliance guide](https://agentadmit.
|
|
|
140
142
|
## License
|
|
141
143
|
|
|
142
144
|
All rights reserved. Patent pending.
|
|
145
|
+
|
|
146
|
+
## Security Alerts
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
alerts = AgentAdmit::AlertsClient.new
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Six alert type constants: `ALERT_TYPE_VOLUME_SPIKE`, `ALERT_TYPE_FAILED_SCOPE_ATTEMPTS`, `ALERT_TYPE_BURST_PATTERN`, `ALERT_TYPE_STALE_REACTIVATION`, `ALERT_TYPE_NEW_SCOPE_USAGE`, `ALERT_TYPE_REVOKED_CONNECTION_ATTEMPT`.
|
|
153
|
+
|
|
154
|
+
### Configure
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
alerts.configure_alerts(
|
|
158
|
+
app_id: 'app_abc123',
|
|
159
|
+
alert_type: AgentAdmit::AlertsClient::ALERT_TYPE_VOLUME_SPIKE,
|
|
160
|
+
enabled: true, threshold_value: 100, threshold_window_minutes: 5,
|
|
161
|
+
kill_switch_enabled: true,
|
|
162
|
+
)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### List Events
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
result = alerts.list_alerts(app_id: 'app_abc123', alert_type: AgentAdmit::AlertsClient::ALERT_TYPE_VOLUME_SPIKE)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Get Config
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
config = alerts.get_alert_config(app_id: 'app_abc123')
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
### Notifying Your Users
|
|
179
|
+
|
|
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 — you deliver it through your own system (in-app notifications, email, push, etc.).
|
|
181
|
+
|
|
182
|
+
- **Poll alerts** — Use the SDK methods above from your backend to check for new events, then notify users through your existing system.
|
|
183
|
+
- **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
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
# Rails controller
|
|
187
|
+
def alerts
|
|
188
|
+
AgentAdmit::Webhook.verify_signature(
|
|
189
|
+
request.raw_post,
|
|
190
|
+
request.headers["X-AgentAdmit-Signature"].to_s,
|
|
191
|
+
AgentAdmit.configuration.webhook_secret # whsec_… from AGENTADMIT_WEBHOOK_SECRET
|
|
192
|
+
)
|
|
193
|
+
event = JSON.parse(request.raw_post)
|
|
194
|
+
# ...
|
|
195
|
+
head :ok
|
|
196
|
+
rescue AgentAdmit::WebhookSignatureError
|
|
197
|
+
head :bad_request
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
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).
|
|
202
|
+
- **React SDK** — Embed the `<AlertsPanel>` component so users can view their own alert history and tighten thresholds.
|
|
203
|
+
|
|
204
|
+
### Issuing & Exchanging Tokens
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
tokens = AgentAdmit::TokensClient.new
|
|
208
|
+
|
|
209
|
+
# Duration is tri-state:
|
|
210
|
+
# omit the argument → AgentAdmit default (30 days)
|
|
211
|
+
# nil → until the user revokes
|
|
212
|
+
# Integer (60–31536000) → explicit seconds
|
|
213
|
+
issued = tokens.issue_token(
|
|
214
|
+
user_id: "user_42",
|
|
215
|
+
scopes: ["read:orders"],
|
|
216
|
+
role: "user",
|
|
217
|
+
duration_seconds: nil # until revoked
|
|
218
|
+
)
|
|
219
|
+
connection_token = issued["token"] # ag_ct_…
|
|
220
|
+
|
|
221
|
+
# Agent side — no API key needed; the connection token is the credential.
|
|
222
|
+
granted = tokens.exchange(connection_token, agent_label: "MyAssistant")
|
|
223
|
+
|
|
224
|
+
# Revoke when the user disconnects the agent.
|
|
225
|
+
tokens.revoke(granted["connection_id"], reason: "user_requested")
|
|
226
|
+
```
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module AgentAdmit
|
|
8
|
+
##
|
|
9
|
+
# AlertsClient — configure and query security alerts via the AgentAdmit hosted service.
|
|
10
|
+
#
|
|
11
|
+
# Supported alert types:
|
|
12
|
+
# ALERT_TYPE_VOLUME_SPIKE, ALERT_TYPE_FAILED_SCOPE_ATTEMPTS,
|
|
13
|
+
# ALERT_TYPE_BURST_PATTERN, ALERT_TYPE_STALE_REACTIVATION,
|
|
14
|
+
# ALERT_TYPE_NEW_SCOPE_USAGE, ALERT_TYPE_REVOKED_CONNECTION_ATTEMPT
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# client = AgentAdmit::AlertsClient.new
|
|
18
|
+
#
|
|
19
|
+
# client.configure_alerts(
|
|
20
|
+
# app_id: "app_abc123",
|
|
21
|
+
# alert_type: AgentAdmit::AlertsClient::ALERT_TYPE_VOLUME_SPIKE,
|
|
22
|
+
# enabled: true,
|
|
23
|
+
# threshold_value: 100,
|
|
24
|
+
# threshold_window_minutes: 5,
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
class AlertsClient
|
|
28
|
+
ALERT_TYPE_VOLUME_SPIKE = "volume_spike"
|
|
29
|
+
ALERT_TYPE_FAILED_SCOPE_ATTEMPTS = "failed_scope_attempts"
|
|
30
|
+
ALERT_TYPE_BURST_PATTERN = "burst_pattern"
|
|
31
|
+
ALERT_TYPE_STALE_REACTIVATION = "stale_reactivation"
|
|
32
|
+
ALERT_TYPE_NEW_SCOPE_USAGE = "new_scope_usage"
|
|
33
|
+
ALERT_TYPE_REVOKED_CONNECTION_ATTEMPT = "revoked_connection_attempt"
|
|
34
|
+
|
|
35
|
+
def initialize(config = nil)
|
|
36
|
+
@config = config || AgentAdmit.configuration || Config.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# Configure alert thresholds for an app or connection.
|
|
41
|
+
# POST /api/v1/alerts
|
|
42
|
+
#
|
|
43
|
+
# @param app_id [String]
|
|
44
|
+
# @param alert_type [String] One of the ALERT_TYPE_* constants
|
|
45
|
+
# @param connection_id [String, nil]
|
|
46
|
+
# @param enabled [Boolean, nil]
|
|
47
|
+
# @param threshold_value [Numeric, nil]
|
|
48
|
+
# @param threshold_window_minutes [Integer, nil]
|
|
49
|
+
# @param threshold_rate_per_minute [Numeric, nil]
|
|
50
|
+
# @param stale_days [Integer, nil]
|
|
51
|
+
# @param kill_switch_enabled [Boolean, nil]
|
|
52
|
+
# @param kill_switch_threshold_value [Numeric, nil]
|
|
53
|
+
# @param kill_switch_threshold_window_minutes [Integer, nil]
|
|
54
|
+
# @return [Hash] { "ok" => true, "config" => {...} }
|
|
55
|
+
# @raise [IntrospectionError]
|
|
56
|
+
#
|
|
57
|
+
def configure_alerts(
|
|
58
|
+
app_id:,
|
|
59
|
+
alert_type:,
|
|
60
|
+
connection_id: nil,
|
|
61
|
+
enabled: nil,
|
|
62
|
+
threshold_value: nil,
|
|
63
|
+
threshold_window_minutes: nil,
|
|
64
|
+
threshold_rate_per_minute: nil,
|
|
65
|
+
stale_days: nil,
|
|
66
|
+
kill_switch_enabled: nil,
|
|
67
|
+
kill_switch_threshold_value: nil,
|
|
68
|
+
kill_switch_threshold_window_minutes: nil
|
|
69
|
+
)
|
|
70
|
+
body = { app_id: app_id, alert_type: alert_type }
|
|
71
|
+
body[:connection_id] = connection_id unless connection_id.nil?
|
|
72
|
+
body[:enabled] = enabled unless enabled.nil?
|
|
73
|
+
body[:threshold_value] = threshold_value unless threshold_value.nil?
|
|
74
|
+
body[:threshold_window_minutes] = threshold_window_minutes unless threshold_window_minutes.nil?
|
|
75
|
+
body[:threshold_rate_per_minute] = threshold_rate_per_minute unless threshold_rate_per_minute.nil?
|
|
76
|
+
body[:stale_days] = stale_days unless stale_days.nil?
|
|
77
|
+
body[:kill_switch_enabled] = kill_switch_enabled unless kill_switch_enabled.nil?
|
|
78
|
+
body[:kill_switch_threshold_value] = kill_switch_threshold_value unless kill_switch_threshold_value.nil?
|
|
79
|
+
body[:kill_switch_threshold_window_minutes] = kill_switch_threshold_window_minutes unless kill_switch_threshold_window_minutes.nil?
|
|
80
|
+
|
|
81
|
+
post_json("/api/v1/alerts", body)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
##
|
|
85
|
+
# List alert events for an app.
|
|
86
|
+
# GET /api/v1/alerts
|
|
87
|
+
#
|
|
88
|
+
# @param app_id [String]
|
|
89
|
+
# @param connection_id [String, nil]
|
|
90
|
+
# @param alert_type [String, nil]
|
|
91
|
+
# @param limit [Integer] default 50
|
|
92
|
+
# @param offset [Integer] default 0
|
|
93
|
+
# @return [Hash] { "events" => [...], "total" => Integer, "limit" => Integer, "offset" => Integer }
|
|
94
|
+
# @raise [IntrospectionError]
|
|
95
|
+
#
|
|
96
|
+
def list_alerts(app_id:, connection_id: nil, alert_type: nil, limit: 50, offset: 0)
|
|
97
|
+
params = { app_id: app_id, limit: limit, offset: offset }
|
|
98
|
+
params[:connection_id] = connection_id if connection_id
|
|
99
|
+
params[:alert_type] = alert_type if alert_type
|
|
100
|
+
|
|
101
|
+
get_json("/api/v1/alerts", params)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
##
|
|
105
|
+
# Get the current alert configuration for an app.
|
|
106
|
+
# GET /api/v1/alerts/config
|
|
107
|
+
#
|
|
108
|
+
# @param app_id [String]
|
|
109
|
+
# @param connection_id [String, nil]
|
|
110
|
+
# @return [Hash] { "app_id", "app_level", "connection_overrides", "alert_types" }
|
|
111
|
+
# @raise [IntrospectionError]
|
|
112
|
+
#
|
|
113
|
+
def get_alert_config(app_id:, connection_id: nil)
|
|
114
|
+
params = { app_id: app_id }
|
|
115
|
+
params[:connection_id] = connection_id if connection_id
|
|
116
|
+
|
|
117
|
+
get_json("/api/v1/alerts/config", params)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def api_base
|
|
123
|
+
base = @config.respond_to?(:api_url) ? @config.api_url : "https://api.agentadmit.com"
|
|
124
|
+
base.to_s.chomp("/")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def auth_headers
|
|
128
|
+
{
|
|
129
|
+
"Authorization" => "Bearer #{@config.api_key}",
|
|
130
|
+
"X-App-Id" => @config.app_id.to_s,
|
|
131
|
+
"Content-Type" => "application/json",
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def post_json(path, body)
|
|
136
|
+
uri = URI.parse("#{api_base}#{path}")
|
|
137
|
+
http = build_http(uri)
|
|
138
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
139
|
+
auth_headers.each { |k, v| req[k] = v }
|
|
140
|
+
req.body = JSON.generate(body)
|
|
141
|
+
|
|
142
|
+
response = http.request(req)
|
|
143
|
+
check_status(response, "POST #{path}")
|
|
144
|
+
JSON.parse(response.body)
|
|
145
|
+
rescue IntrospectionError
|
|
146
|
+
raise
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
raise IntrospectionError, "AgentAdmit alerts request failed: #{e.message}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def get_json(path, params = {})
|
|
152
|
+
query = URI.encode_www_form(params)
|
|
153
|
+
uri = URI.parse("#{api_base}#{path}?#{query}")
|
|
154
|
+
http = build_http(uri)
|
|
155
|
+
req = Net::HTTP::Get.new("#{uri.path}?#{uri.query}")
|
|
156
|
+
auth_headers.each { |k, v| req[k] = v }
|
|
157
|
+
|
|
158
|
+
response = http.request(req)
|
|
159
|
+
check_status(response, "GET #{path}")
|
|
160
|
+
JSON.parse(response.body)
|
|
161
|
+
rescue IntrospectionError
|
|
162
|
+
raise
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
raise IntrospectionError, "AgentAdmit alerts request failed: #{e.message}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_http(uri)
|
|
168
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
169
|
+
http.use_ssl = uri.scheme == "https"
|
|
170
|
+
http.read_timeout = 10
|
|
171
|
+
http.open_timeout = 5
|
|
172
|
+
http
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def check_status(response, operation)
|
|
176
|
+
status = response.code.to_i
|
|
177
|
+
return if status < 400
|
|
178
|
+
|
|
179
|
+
raise IntrospectionError,
|
|
180
|
+
"AgentAdmit #{operation} failed with HTTP #{status}: #{response.body}"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
data/lib/agentadmit/config.rb
CHANGED
|
@@ -9,17 +9,33 @@ module AgentAdmit
|
|
|
9
9
|
class Config
|
|
10
10
|
attr_accessor :app_id, :api_key, :verify_url, :api_url,
|
|
11
11
|
:token_prefix_access, :token_prefix_connection,
|
|
12
|
-
:max_retries
|
|
12
|
+
:webhook_secret, :max_retries
|
|
13
13
|
|
|
14
14
|
def initialize
|
|
15
15
|
@app_id = ENV.fetch("AGENTADMIT_APP_ID", "")
|
|
16
16
|
@api_key = ENV.fetch("AGENTADMIT_API_KEY", "")
|
|
17
|
-
@verify_url = ENV.fetch("AGENTADMIT_VERIFY_URL", "https://api.agentadmit.com/v1/verify")
|
|
17
|
+
@verify_url = ENV.fetch("AGENTADMIT_VERIFY_URL", "https://api.agentadmit.com/api/v1/verify")
|
|
18
18
|
@api_url = ENV.fetch("AGENTADMIT_API_URL", "https://api.agentadmit.com")
|
|
19
19
|
@token_prefix_access = "ag_at_"
|
|
20
20
|
@token_prefix_connection = "ag_ct_"
|
|
21
|
+
# Webhook signing secret (whsec_…) — shown once when you configure the
|
|
22
|
+
# alert webhook URL in the dashboard. Used by AgentAdmit::Webhook.
|
|
23
|
+
@webhook_secret = ENV.fetch("AGENTADMIT_WEBHOOK_SECRET", "")
|
|
21
24
|
# Max retries on HTTP 429 before raising RateLimitError. Default: 3.
|
|
22
25
|
@max_retries = ENV.fetch("AGENTADMIT_MAX_RETRIES", "3").to_i
|
|
23
26
|
end
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# Validate the API key prefix (aa_test_/aa_live_) without ever echoing
|
|
30
|
+
# the key itself.
|
|
31
|
+
#
|
|
32
|
+
# @raise [ConfigurationError] if a non-empty key has the wrong prefix
|
|
33
|
+
#
|
|
34
|
+
def validate_api_key!
|
|
35
|
+
return if api_key.nil? || api_key.empty?
|
|
36
|
+
return if api_key.start_with?("aa_test_", "aa_live_")
|
|
37
|
+
|
|
38
|
+
raise ConfigurationError, "api_key must start with 'aa_test_' or 'aa_live_'"
|
|
39
|
+
end
|
|
24
40
|
end
|
|
25
41
|
end
|
|
@@ -10,7 +10,8 @@ module AgentAdmit
|
|
|
10
10
|
# No local JWT decode. Every verification call goes through AgentAdmit.
|
|
11
11
|
#
|
|
12
12
|
class IntrospectionClient
|
|
13
|
-
IntrospectionResult = Struct.new(:user_id, :connection_id, :scopes, :agent_label,
|
|
13
|
+
IntrospectionResult = Struct.new(:user_id, :connection_id, :scopes, :agent_label,
|
|
14
|
+
:sub, :role, :app_id, :jti, :exp, keyword_init: true) do
|
|
14
15
|
def has_scope?(scope)
|
|
15
16
|
scopes.include?(scope)
|
|
16
17
|
end
|
|
@@ -18,6 +19,7 @@ module AgentAdmit
|
|
|
18
19
|
|
|
19
20
|
def initialize(config = nil)
|
|
20
21
|
@config = config || AgentAdmit.configuration || Config.new
|
|
22
|
+
@config.validate_api_key!
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
##
|
|
@@ -89,11 +91,19 @@ module AgentAdmit
|
|
|
89
91
|
data = JSON.parse(response.body)
|
|
90
92
|
|
|
91
93
|
# Check active flag (RFC 7662 introspection pattern).
|
|
92
|
-
# The verify endpoint returns {active: false} with HTTP 200 for
|
|
93
|
-
# expired/revoked tokens
|
|
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.
|
|
94
98
|
unless data["active"]
|
|
95
99
|
reason = data["error"] || "invalid_token"
|
|
96
|
-
raise InvalidTokenError
|
|
100
|
+
raise InvalidTokenError.new("Token is not active: #{reason}", code: reason)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# insufficient_scope arrives with active: true (token valid,
|
|
104
|
+
# requested scope not granted).
|
|
105
|
+
if data["error"] == "insufficient_scope"
|
|
106
|
+
raise InsufficientScopeError, data["error_description"] || "Scope not granted"
|
|
97
107
|
end
|
|
98
108
|
|
|
99
109
|
raise InvalidTokenError, "Introspection returned no user" if data["user_id"].nil?
|
|
@@ -102,7 +112,12 @@ module AgentAdmit
|
|
|
102
112
|
user_id: data["user_id"],
|
|
103
113
|
connection_id: data["connection_id"],
|
|
104
114
|
scopes: data["scopes"] || [],
|
|
105
|
-
agent_label: data["agent_label"] || "Unknown Agent"
|
|
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"]
|
|
106
121
|
)
|
|
107
122
|
when 401
|
|
108
123
|
data = JSON.parse(response.body) rescue {}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module AgentAdmit
|
|
8
|
+
##
|
|
9
|
+
# TokensClient — issue, exchange, and revoke connection tokens via the
|
|
10
|
+
# AgentAdmit hosted service.
|
|
11
|
+
#
|
|
12
|
+
class TokensClient
|
|
13
|
+
# Sentinel for issue_token's duration_seconds: leave the field out of the
|
|
14
|
+
# request entirely, so AgentAdmit applies its default (30 days). Pass nil
|
|
15
|
+
# instead for an until-revoked connection (explicit JSON null).
|
|
16
|
+
UNSET = Object.new.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(config = nil)
|
|
19
|
+
@config = config || AgentAdmit.configuration || Config.new
|
|
20
|
+
@config.validate_api_key!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# Issue a connection token for one of your users.
|
|
25
|
+
# Calls POST /api/v1/apps/{app_id}/token.
|
|
26
|
+
#
|
|
27
|
+
# The duration is tri-state:
|
|
28
|
+
# - omit the argument — field omitted; AgentAdmit applies its default
|
|
29
|
+
# (30 days)
|
|
30
|
+
# - nil — explicit JSON null; the connection lasts until revoked
|
|
31
|
+
# - Integer — explicit duration in seconds (60–31536000)
|
|
32
|
+
#
|
|
33
|
+
# @param user_id [String] your app's identifier for the user
|
|
34
|
+
# @param scopes [Array<String>] scopes the connection grants
|
|
35
|
+
# @param role [String, nil] the user's role on the connection
|
|
36
|
+
# @param duration_seconds [Integer, nil, UNSET] see above
|
|
37
|
+
# @return [Hash] the issue response — "token" is the self-describing
|
|
38
|
+
# ag_ct_… connection token to hand to the user's agent
|
|
39
|
+
# @raise [IntrospectionError] if issuance fails
|
|
40
|
+
#
|
|
41
|
+
def issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET)
|
|
42
|
+
body = { "user_id" => user_id, "scopes" => scopes }
|
|
43
|
+
body["role"] = role if role
|
|
44
|
+
# Tri-state: the UNSET sentinel omits the key entirely; nil survives
|
|
45
|
+
# JSON.generate as explicit JSON null (no compact, no nil-guard).
|
|
46
|
+
body["duration_seconds"] = duration_seconds unless duration_seconds.equal?(UNSET)
|
|
47
|
+
|
|
48
|
+
post("/api/v1/apps/#{@config.app_id}/token", body, authenticated: true, op: "issue_token")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
# Exchange a single-use connection token for an access token.
|
|
53
|
+
# Calls POST /api/v1/exchange — unauthenticated by design: the connection
|
|
54
|
+
# token itself is the credential, so the operator API key is NOT sent.
|
|
55
|
+
#
|
|
56
|
+
# @param connection_token [String] the ag_ct_… connection token
|
|
57
|
+
# @param agent_label [String, nil] human-readable agent name
|
|
58
|
+
# @param agent_id [String, nil] agent identifier
|
|
59
|
+
# @return [Hash] the exchange response — "access_token" is the ag_at_… token
|
|
60
|
+
# @raise [IntrospectionError] if the exchange fails
|
|
61
|
+
#
|
|
62
|
+
def exchange(connection_token, agent_label: nil, agent_id: nil)
|
|
63
|
+
body = { "token" => connection_token }
|
|
64
|
+
body["agent_label"] = agent_label if agent_label
|
|
65
|
+
body["agent_id"] = agent_id if agent_id
|
|
66
|
+
|
|
67
|
+
post("/api/v1/exchange", body, authenticated: false, op: "exchange")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
##
|
|
71
|
+
# Revoke a connection (and its access tokens).
|
|
72
|
+
# Calls POST /api/v1/revoke.
|
|
73
|
+
#
|
|
74
|
+
# @param connection_id [String] the connection to revoke
|
|
75
|
+
# @param reason [String, nil] optional human-readable reason
|
|
76
|
+
# @return [Hash] the revoke response — { "ok" => true, ... }
|
|
77
|
+
# @raise [IntrospectionError] if the revocation fails
|
|
78
|
+
#
|
|
79
|
+
def revoke(connection_id, reason: nil)
|
|
80
|
+
body = { "connection_id" => connection_id }
|
|
81
|
+
body["reason"] = reason if reason
|
|
82
|
+
|
|
83
|
+
post("/api/v1/revoke", body, authenticated: true, op: "revoke")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def post(path, body, authenticated:, op:)
|
|
89
|
+
uri = URI.parse("#{@config.api_url.chomp('/')}#{path}")
|
|
90
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
91
|
+
http.use_ssl = uri.scheme == "https"
|
|
92
|
+
http.read_timeout = 10
|
|
93
|
+
http.open_timeout = 10
|
|
94
|
+
|
|
95
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
96
|
+
request["Content-Type"] = "application/json"
|
|
97
|
+
if authenticated
|
|
98
|
+
# No Authorization on /exchange — the connection token is the credential.
|
|
99
|
+
request["Authorization"] = "Bearer #{@config.api_key}"
|
|
100
|
+
request["X-App-Id"] = @config.app_id
|
|
101
|
+
end
|
|
102
|
+
request.body = JSON.generate(body)
|
|
103
|
+
|
|
104
|
+
begin
|
|
105
|
+
response = http.request(request)
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
raise IntrospectionError, "#{op} failed: #{e.message}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
status = response.code.to_i
|
|
111
|
+
raise IntrospectionError, "#{op} returned #{status}" if status >= 400
|
|
112
|
+
|
|
113
|
+
JSON.parse(response.body)
|
|
114
|
+
rescue JSON::ParserError
|
|
115
|
+
raise IntrospectionError, "#{op} returned an unparseable response"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/agentadmit/version.rb
CHANGED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module AgentAdmit
|
|
6
|
+
##
|
|
7
|
+
# Verification for inbound AgentAdmit alert webhooks.
|
|
8
|
+
#
|
|
9
|
+
# AgentAdmit signs every alert webhook delivery with the app's webhook
|
|
10
|
+
# signing secret (whsec_…, returned once when the webhook URL is
|
|
11
|
+
# configured). The signature arrives in the X-AgentAdmit-Signature header:
|
|
12
|
+
#
|
|
13
|
+
# X-AgentAdmit-Signature: t=<unix_ts>,v1=<hex hmac-sha256>
|
|
14
|
+
#
|
|
15
|
+
# where the HMAC input is "{t}.{raw_body}" keyed with the full whsec_
|
|
16
|
+
# secret. Always verify against the raw request body (request.raw_post in
|
|
17
|
+
# Rails), before any JSON parsing.
|
|
18
|
+
#
|
|
19
|
+
# @example Rails controller
|
|
20
|
+
# def alerts
|
|
21
|
+
# AgentAdmit::Webhook.verify_signature(
|
|
22
|
+
# request.raw_post,
|
|
23
|
+
# request.headers["X-AgentAdmit-Signature"].to_s,
|
|
24
|
+
# AgentAdmit.configuration.webhook_secret
|
|
25
|
+
# )
|
|
26
|
+
# event = JSON.parse(request.raw_post)
|
|
27
|
+
# # ...
|
|
28
|
+
# rescue AgentAdmit::WebhookSignatureError
|
|
29
|
+
# head :bad_request
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
module Webhook
|
|
33
|
+
# Header AgentAdmit signs alert webhook deliveries with.
|
|
34
|
+
SIGNATURE_HEADER = "X-AgentAdmit-Signature"
|
|
35
|
+
|
|
36
|
+
# Default maximum clock skew (seconds) allowed for replay protection.
|
|
37
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# Verify the X-AgentAdmit-Signature header on an inbound alert webhook.
|
|
43
|
+
#
|
|
44
|
+
# @param payload [String] the raw request body
|
|
45
|
+
# @param header [String] the X-AgentAdmit-Signature header value
|
|
46
|
+
# @param secret [String] the app's webhook signing secret (whsec_…)
|
|
47
|
+
# @param tolerance [Integer] max clock skew in seconds (0 disables the check)
|
|
48
|
+
# @param now [Integer, nil] override the current Unix timestamp (for tests)
|
|
49
|
+
# @raise [WebhookSignatureError] if the header is missing/malformed, the
|
|
50
|
+
# timestamp is outside the tolerance window, or no signature matches;
|
|
51
|
+
# the message never includes the secret or the payload
|
|
52
|
+
# @return [void]
|
|
53
|
+
#
|
|
54
|
+
def verify_signature(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS, now: nil)
|
|
55
|
+
raise WebhookSignatureError, "Webhook signing secret is required" if secret.nil? || secret.empty?
|
|
56
|
+
raise WebhookSignatureError, "Missing X-AgentAdmit-Signature header" if header.nil? || header.empty?
|
|
57
|
+
|
|
58
|
+
timestamp = nil
|
|
59
|
+
candidates = []
|
|
60
|
+
header.split(",").each do |part|
|
|
61
|
+
key, _, value = part.strip.partition("=")
|
|
62
|
+
case key
|
|
63
|
+
when "t"
|
|
64
|
+
raise WebhookSignatureError, "Malformed signature header" unless value.match?(/\A\d+\z/)
|
|
65
|
+
|
|
66
|
+
timestamp = Integer(value)
|
|
67
|
+
when "v1"
|
|
68
|
+
candidates << value
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
raise WebhookSignatureError, "Malformed signature header" if timestamp.nil? || candidates.empty?
|
|
73
|
+
|
|
74
|
+
if tolerance.positive? && ((now || Time.now.to_i) - timestamp).abs > tolerance
|
|
75
|
+
raise WebhookSignatureError, "Signature timestamp outside tolerance window"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
|
|
79
|
+
matched = candidates.any? { |candidate| secure_compare(expected, candidate) }
|
|
80
|
+
|
|
81
|
+
raise WebhookSignatureError, "Webhook signature verification failed" unless matched
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
##
|
|
85
|
+
# Boolean form of {verify_signature}.
|
|
86
|
+
#
|
|
87
|
+
# @return [Boolean]
|
|
88
|
+
#
|
|
89
|
+
def valid_signature?(payload, header, secret, tolerance: DEFAULT_TOLERANCE_SECONDS, now: nil)
|
|
90
|
+
verify_signature(payload, header, secret, tolerance: tolerance, now: now)
|
|
91
|
+
true
|
|
92
|
+
rescue WebhookSignatureError
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
##
|
|
97
|
+
# Constant-time string comparison (portable — OpenSSL.secure_compare is
|
|
98
|
+
# not available on every openssl gem version).
|
|
99
|
+
#
|
|
100
|
+
# @api private
|
|
101
|
+
#
|
|
102
|
+
def secure_compare(expected, candidate)
|
|
103
|
+
return false unless expected.bytesize == candidate.bytesize
|
|
104
|
+
|
|
105
|
+
diff = 0
|
|
106
|
+
expected_bytes = expected.unpack("C*")
|
|
107
|
+
candidate.each_byte.with_index { |byte, i| diff |= byte ^ expected_bytes[i] }
|
|
108
|
+
diff.zero?
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/agentadmit.rb
CHANGED
|
@@ -1,17 +1,49 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "agentadmit/version"
|
|
4
|
-
require_relative "agentadmit/config"
|
|
5
|
-
require_relative "agentadmit/introspection_client"
|
|
6
|
-
require_relative "agentadmit/middleware"
|
|
7
|
-
require_relative "agentadmit/scope_enforcement"
|
|
8
|
-
require_relative "agentadmit/railtie" if defined?(Rails)
|
|
9
4
|
|
|
10
5
|
module AgentAdmit
|
|
11
6
|
class Error < StandardError; end
|
|
12
|
-
|
|
7
|
+
|
|
8
|
+
##
|
|
9
|
+
# Raised when token validation fails. {#code} carries the machine-readable
|
|
10
|
+
# reason from the API — one of VERIFY_ERROR_CODES (e.g. token_expired,
|
|
11
|
+
# connection_expired, environment_mismatch); unknown codes pass through.
|
|
12
|
+
#
|
|
13
|
+
class InvalidTokenError < Error
|
|
14
|
+
# @return [String] machine-readable error code
|
|
15
|
+
attr_reader :code
|
|
16
|
+
|
|
17
|
+
def initialize(message = "Invalid access token", code: "invalid_token")
|
|
18
|
+
super(message)
|
|
19
|
+
@code = code
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
13
23
|
class InsufficientScopeError < Error; end
|
|
14
24
|
class IntrospectionError < Error; end
|
|
25
|
+
class ConfigurationError < Error; end
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
# Raised when an inbound alert webhook fails X-AgentAdmit-Signature
|
|
29
|
+
# verification.
|
|
30
|
+
#
|
|
31
|
+
class WebhookSignatureError < Error; end
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# Error codes /api/v1/verify returns with HTTP 200 and active: false
|
|
35
|
+
# (insufficient_scope arrives with active: true — token valid, scope not
|
|
36
|
+
# granted).
|
|
37
|
+
#
|
|
38
|
+
VERIFY_ERROR_CODES = %w[
|
|
39
|
+
invalid_token
|
|
40
|
+
token_expired
|
|
41
|
+
token_revoked
|
|
42
|
+
connection_revoked
|
|
43
|
+
connection_expired
|
|
44
|
+
environment_mismatch
|
|
45
|
+
insufficient_scope
|
|
46
|
+
].freeze
|
|
15
47
|
|
|
16
48
|
##
|
|
17
49
|
# Raised when the AgentAdmit introspection endpoint returns HTTP 429 and
|
|
@@ -53,3 +85,13 @@ module AgentAdmit
|
|
|
53
85
|
end
|
|
54
86
|
end
|
|
55
87
|
end
|
|
88
|
+
|
|
89
|
+
require_relative "agentadmit/config"
|
|
90
|
+
require_relative "agentadmit/introspection_client"
|
|
91
|
+
require_relative "agentadmit/tokens_client"
|
|
92
|
+
require_relative "agentadmit/alerts_client"
|
|
93
|
+
require_relative "agentadmit/webhook"
|
|
94
|
+
require_relative "agentadmit/middleware"
|
|
95
|
+
# ScopeEnforcement is an ActiveSupport::Concern — only loadable inside Rails.
|
|
96
|
+
require_relative "agentadmit/scope_enforcement" if defined?(ActiveSupport)
|
|
97
|
+
require_relative "agentadmit/railtie" if defined?(Rails)
|
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.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Christopher Emerson
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|
|
@@ -26,7 +26,7 @@ dependencies:
|
|
|
26
26
|
version: '0'
|
|
27
27
|
description: Integrate AgentAdmit into your Rails app. Mandatory introspection, scope
|
|
28
28
|
enforcement, and secure AI agent connections.
|
|
29
|
-
email:
|
|
29
|
+
email:
|
|
30
30
|
executables: []
|
|
31
31
|
extensions: []
|
|
32
32
|
extra_rdoc_files: []
|
|
@@ -34,17 +34,20 @@ files:
|
|
|
34
34
|
- LICENSE
|
|
35
35
|
- README.md
|
|
36
36
|
- lib/agentadmit.rb
|
|
37
|
+
- lib/agentadmit/alerts_client.rb
|
|
37
38
|
- lib/agentadmit/config.rb
|
|
38
39
|
- lib/agentadmit/introspection_client.rb
|
|
39
40
|
- lib/agentadmit/middleware.rb
|
|
40
41
|
- lib/agentadmit/railtie.rb
|
|
41
42
|
- lib/agentadmit/scope_enforcement.rb
|
|
43
|
+
- lib/agentadmit/tokens_client.rb
|
|
42
44
|
- lib/agentadmit/version.rb
|
|
45
|
+
- lib/agentadmit/webhook.rb
|
|
43
46
|
homepage: https://agentadmit.com/docs
|
|
44
47
|
licenses:
|
|
45
48
|
- Nonstandard
|
|
46
49
|
metadata: {}
|
|
47
|
-
post_install_message:
|
|
50
|
+
post_install_message:
|
|
48
51
|
rdoc_options: []
|
|
49
52
|
require_paths:
|
|
50
53
|
- lib
|
|
@@ -59,8 +62,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
59
62
|
- !ruby/object:Gem::Version
|
|
60
63
|
version: '0'
|
|
61
64
|
requirements: []
|
|
62
|
-
rubygems_version: 3.
|
|
63
|
-
signing_key:
|
|
65
|
+
rubygems_version: 3.4.19
|
|
66
|
+
signing_key:
|
|
64
67
|
specification_version: 4
|
|
65
68
|
summary: AgentAdmit SDK for Ruby on Rails — User-mediated AI agent authorization
|
|
66
69
|
test_files: []
|