agentadmit 1.4.0 → 1.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc3085f5469fd5380afb22df98cf1517e1f74df3a23cf7ab07d7f6c490cd9feb
4
- data.tar.gz: fb0142a79357486644fff3e68903194b998cf5b41c1f140a8bda8f7a2f0c2909
3
+ metadata.gz: 885a1ed3458153db1420a5b58acf902aafecdea120ef3e51ce238c25e635562a
4
+ data.tar.gz: d0477df5f1302c204fb751a28343a8986ed964e18c814f5b72abfb274037a1e3
5
5
  SHA512:
6
- metadata.gz: e54b1b2f7c357b546388d09a9accf94c29c98964ade0d5888e87962d3d30219486cf30b932234d503e46d6ea80e4a6348a165a6172a09b4d182390709c605713
7
- data.tar.gz: 3d237b464785de4d0a8a081d992dd7313c77c84a3bdac92f0a3a9dc41a2909137cfec0415d7eb67dd4408c1eb772d24ff514e996184ad0d580b9f047fcd0a59d
6
+ metadata.gz: ad67e5e7b68872e5f9004e0da11b22d5ce7337f56725338fa9678e209e8689706f135fd0b4cd5eb042760944826757bcfc372f9dbbd223eb94a853e6692cbd75
7
+ data.tar.gz: 27780e1514e4296eba0aa2e345b888de8cbc74e44326ebbec4c3e0abd694c0026862387987b9264774bee8edffa125b44faf19b12e43d85cb36816bac7322c18
data/README.md CHANGED
@@ -96,6 +96,22 @@ head :forbidden unless verdict["granted"]
96
96
 
97
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
98
 
99
+ **One-middleware drop-in.** Instead of wiring the three paths by hand, `AgentAdmit::CallerConsent` classifies the caller from the credential and evaluates the right independent path:
100
+
101
+ ```ruby
102
+ use AgentAdmit::CallerConsent,
103
+ # derive the class from your own credential structure, never caller input
104
+ classify_non_agent: ->(env) {
105
+ env["HTTP_X_INTERNAL_AI"] == ENV["INTERNAL_AI_SECRET"] ? "in_app_ai" : "human_session"
106
+ },
107
+ resolve_data_owner_id: ->(env) { Rack::Request.new(env).params["owner_id"] },
108
+ required_scope: "read:records"
109
+ # Downstream: env["agentadmit.caller_class"], env["agentadmit.consent"], and the
110
+ # standard agent env variables on the external-agent path.
111
+ ```
112
+
113
+ External agents are checked via hosted introspection (consent verdict plus scope); in-app AI via the Consent Ledger (fail closed); the human path defers to your own permission model unless `gate_human: true`. It is a consent gate, not an authenticator, so mount it after your own authentication.
114
+
99
115
  ### Presence verification
100
116
 
101
117
  AgentAdmit can attest that the human who authorized a connection completed a WebAuthn presence ceremony on the consent page. The verify result carries the fact:
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentAdmit
4
+ ##
5
+ # Caller-Identity Consent middleware: the "classify caller, then gate the
6
+ # right independent path" recipe as one Rack middleware, so an app owner
7
+ # does not have to hand-roll it.
8
+ #
9
+ # One endpoint serves every caller class. On each request the middleware:
10
+ #
11
+ # 1. classifies the caller from the STRUCTURE of the credential (a class
12
+ # the caller cannot self-select), before any consent check;
13
+ # 2. routes to that class's ISOLATED consent path; no path reads or
14
+ # inherits another class's preference;
15
+ # 3. permits or denies, and sets env variables
16
+ # (agentadmit.caller_class, agentadmit.consent, plus the standard agent
17
+ # env variables on the external-agent path).
18
+ #
19
+ # - external_agent: an ag_at_ access token -> hosted introspection, which
20
+ # returns the external-agent consent verdict inline plus the granted
21
+ # scopes. Enforced here directly.
22
+ # - in_app_ai: your application's own server-side AI code path -> the
23
+ # Consent Ledger /consent/check for the in-app-AI class.
24
+ # - human_session: your application's own permission model (sharing,
25
+ # roles, grants). Deferred to your existing authorization by default;
26
+ # opt in to a stored human-session switch with gate_human: true.
27
+ #
28
+ # The three decisions are independent: granting one never grants another.
29
+ #
30
+ # SECURITY: this is a consent gate, not an authenticator. It classifies the
31
+ # caller and enforces the per-class CONSENT decision; it does not by itself
32
+ # authenticate a human session. Mount it AFTER your own authentication. On
33
+ # the human_session path it defers to your application's permission model
34
+ # and calls the app without re-authenticating. The external_agent path is
35
+ # always authenticated (hosted introspection); the in_app_ai path always
36
+ # evaluates the ledger.
37
+ #
38
+ # Usage:
39
+ # use AgentAdmit::CallerConsent,
40
+ # # derive the class from your own credential structure, never caller input
41
+ # classify_non_agent: ->(env) {
42
+ # env["HTTP_X_INTERNAL_AI"] == ENV["INTERNAL_AI_SECRET"] ? "in_app_ai" : "human_session"
43
+ # },
44
+ # resolve_data_owner_id: ->(env) { Rack::Request.new(env).params["owner_id"] },
45
+ # required_scope: "read:records"
46
+ #
47
+ class CallerConsent
48
+ # RFC 7235: the auth-scheme token is case-insensitive.
49
+ BEARER_AGENT_RE = /\Abearer ag_at_/i
50
+
51
+ HUMAN_SESSION = "human_session"
52
+ IN_APP_AI = "in_app_ai"
53
+ EXTERNAL_AGENT = "external_agent"
54
+
55
+ ##
56
+ # @param app [#call] the downstream Rack app
57
+ # @param resolve_data_owner_id [Proc, nil] env -> your app's identifier
58
+ # for the data owner whose resource is accessed. Required for the
59
+ # in_app_ai path, and for human_session when gate_human is set. The
60
+ # external-agent owner comes from the token, so it is not used there.
61
+ # @param classify_non_agent [Proc, nil] env -> "in_app_ai" or
62
+ # "human_session", derived from the STRUCTURE of the credential (for
63
+ # example an internal service token), never a value the caller can set.
64
+ # Defaults to treating non-agent callers as human sessions.
65
+ # @param required_scope [String, nil] for the external_agent path,
66
+ # require this scope (403 insufficient_scope if not granted).
67
+ # @param scope_group [String, nil] optional finer-than-class consent
68
+ # group for ledger checks.
69
+ # @param gate_human [Boolean] also gate the human_session class against a
70
+ # stored switch. Off by default: the human path belongs to your own
71
+ # permission model.
72
+ #
73
+ def initialize(app, resolve_data_owner_id: nil, classify_non_agent: nil,
74
+ required_scope: nil, scope_group: nil, gate_human: false)
75
+ @app = app
76
+ @client = IntrospectionClient.new
77
+ @resolve_data_owner_id = resolve_data_owner_id
78
+ @classify_non_agent = classify_non_agent
79
+ @required_scope = required_scope
80
+ @scope_group = scope_group
81
+ @gate_human = gate_human
82
+ end
83
+
84
+ ##
85
+ # Classify the caller from credential structure, before any consent
86
+ # check. An ag_at_ bearer token is an external agent; anything else is
87
+ # resolved by classify_non_agent (default: human_session). The class is
88
+ # derived, never self-selected by the caller.
89
+ #
90
+ def classify_caller(env)
91
+ auth = env["HTTP_AUTHORIZATION"] || ""
92
+ return EXTERNAL_AGENT if BEARER_AGENT_RE.match?(auth)
93
+ return IN_APP_AI if @classify_non_agent && @classify_non_agent.call(env) == IN_APP_AI
94
+
95
+ HUMAN_SESSION
96
+ end
97
+
98
+ def call(env)
99
+ caller_class = classify_caller(env)
100
+ env["agentadmit.caller_class"] = caller_class
101
+
102
+ case caller_class
103
+ when EXTERNAL_AGENT
104
+ call_external_agent(env)
105
+ when IN_APP_AI
106
+ call_ledger_gated(env, IN_APP_AI, "in_app_ai",
107
+ "The data owner has not enabled in-app AI analysis.")
108
+ else
109
+ if @gate_human
110
+ call_ledger_gated(env, HUMAN_SESSION, "user",
111
+ "The data owner has not enabled this access.")
112
+ else
113
+ # Defer the human path to the app's existing authorization.
114
+ env["agentadmit.auth_type"] = "user"
115
+ @app.call(env)
116
+ end
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ ##
123
+ # External-agent path: hosted introspection carries the verdict and the
124
+ # scopes. A present-and-denied verdict fails closed; an absent verdict
125
+ # means the platform default (external-agent allowed) held.
126
+ #
127
+ def call_external_agent(env)
128
+ token = (env["HTTP_AUTHORIZATION"] || "").sub(/\Abearer /i, "")
129
+
130
+ begin
131
+ result = @client.verify(token)
132
+ rescue InvalidTokenError => e
133
+ return json_error(401, "invalid_token", e.message)
134
+ rescue IntrospectionError => e
135
+ return json_error(502, "introspection_failed", e.message)
136
+ end
137
+
138
+ if @required_scope && !(result.scopes || []).include?(@required_scope)
139
+ return [403, { "Content-Type" => "application/json" },
140
+ [{ error: "insufficient_scope",
141
+ required_scope: @required_scope,
142
+ granted_scopes: result.scopes || [],
143
+ message: "This action requires '#{@required_scope}' scope." }.to_json]]
144
+ end
145
+
146
+ return json_error(403, "consent_not_granted",
147
+ "The data owner has not enabled external agent access.") unless result.consent_granted?
148
+
149
+ env["agentadmit.auth_type"] = "agent"
150
+ env["agentadmit.user_id"] = result.user_id
151
+ env["agentadmit.scopes"] = result.scopes
152
+ env["agentadmit.connection_id"] = result.connection_id
153
+ env["agentadmit.agent_label"] = result.agent_label
154
+ env["agentadmit.presence"] = result.presence
155
+ env["agentadmit.consent"] = result.consent if result.consent
156
+
157
+ @app.call(env)
158
+ end
159
+
160
+ ##
161
+ # Token-less caller class (in_app_ai, or human_session under gate_human),
162
+ # gated on the Consent Ledger. Fail closed: an unreachable or erroring
163
+ # ledger denies, never allows.
164
+ #
165
+ def call_ledger_gated(env, caller_class, auth_type, denied_message)
166
+ owner = @resolve_data_owner_id&.call(env)
167
+ if owner.nil? || owner.empty?
168
+ return json_error(500, "server_error",
169
+ "resolve_data_owner_id is required for this caller class")
170
+ end
171
+
172
+ begin
173
+ verdict = @client.check_consent(app_user_id: owner, caller_class: caller_class,
174
+ scope_group: @scope_group)
175
+ rescue StandardError
176
+ return json_error(503, "consent_unavailable", "Consent check failed")
177
+ end
178
+
179
+ return json_error(403, "consent_not_granted", denied_message) unless verdict["granted"] == true
180
+
181
+ env["agentadmit.auth_type"] = auth_type
182
+ env["agentadmit.consent"] = verdict
183
+ @app.call(env)
184
+ end
185
+
186
+ def json_error(status, error, description)
187
+ [status, { "Content-Type" => "application/json" },
188
+ [{ error: error, error_description: description }.to_json]]
189
+ end
190
+ end
191
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.4.0"
4
+ VERSION = "1.5.0"
5
5
  end
data/lib/agentadmit.rb CHANGED
@@ -92,6 +92,7 @@ require_relative "agentadmit/tokens_client"
92
92
  require_relative "agentadmit/alerts_client"
93
93
  require_relative "agentadmit/webhook"
94
94
  require_relative "agentadmit/middleware"
95
+ require_relative "agentadmit/caller_consent"
95
96
  # ScopeEnforcement is an ActiveSupport::Concern — only loadable inside Rails.
96
97
  require_relative "agentadmit/scope_enforcement" if defined?(ActiveSupport)
97
98
  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.0
4
+ version: 1.5.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-07-05 00:00:00.000000000 Z
11
+ date: 2026-07-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -35,6 +35,7 @@ files:
35
35
  - README.md
36
36
  - lib/agentadmit.rb
37
37
  - lib/agentadmit/alerts_client.rb
38
+ - lib/agentadmit/caller_consent.rb
38
39
  - lib/agentadmit/config.rb
39
40
  - lib/agentadmit/introspection_client.rb
40
41
  - lib/agentadmit/middleware.rb