@creatornader/agent-bridge 0.4.0 → 0.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/README.md +73 -7
  3. package/ROADMAP.md +29 -0
  4. package/SECURITY.md +23 -0
  5. package/SKILL.md +5 -1
  6. package/dist/archive.d.ts +2 -2
  7. package/dist/{bridge-service-YTjGr91A.d.ts → bridge-service-CNBKdYXh.d.ts} +2 -1
  8. package/dist/{bridge-store-Boaj-ZbO.d.ts → bridge-store-ByJ6CaJk.d.ts} +1 -0
  9. package/dist/bridge.d.ts +15 -8
  10. package/dist/bridge.js +10 -8
  11. package/dist/{chunk-PDIHDBP3.js → chunk-7VJI2MXI.js} +116 -26
  12. package/dist/chunk-7VJI2MXI.js.map +1 -0
  13. package/dist/{chunk-4VB44KJU.js → chunk-AWE3DFZV.js} +3 -2
  14. package/dist/chunk-AWE3DFZV.js.map +1 -0
  15. package/dist/{chunk-IHZFI7TH.js → chunk-E4OWHGUE.js} +5 -2
  16. package/dist/chunk-E4OWHGUE.js.map +1 -0
  17. package/dist/{chunk-SYGONYMM.js → chunk-HUHG7XTB.js} +6 -5
  18. package/dist/chunk-HUHG7XTB.js.map +1 -0
  19. package/dist/{chunk-DQOZJJZ2.js → chunk-IVG4XWWS.js} +5 -5
  20. package/dist/{chunk-DQOZJJZ2.js.map → chunk-IVG4XWWS.js.map} +1 -1
  21. package/dist/{chunk-3YHVUPU2.js → chunk-P635FAN4.js} +4 -4
  22. package/dist/chunk-P635FAN4.js.map +1 -0
  23. package/dist/{chunk-6BTJE6D4.js → chunk-QNXHDOJ5.js} +56 -23
  24. package/dist/chunk-QNXHDOJ5.js.map +1 -0
  25. package/dist/{chunk-MCKKVNCE.js → chunk-QPC6JHA3.js} +5 -5
  26. package/dist/{chunk-SV3SVBYS.js → chunk-SN7PIVDF.js} +2 -2
  27. package/dist/{chunk-2DFNOLMO.js → chunk-XDNT6ZNZ.js} +9 -6
  28. package/dist/chunk-XDNT6ZNZ.js.map +1 -0
  29. package/dist/{chunk-LAHNQZDN.js → chunk-Z6JP6MYD.js} +2 -2
  30. package/dist/cli.js +9 -9
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-runtime.d.ts +3 -3
  33. package/dist/client-runtime.js +6 -6
  34. package/dist/contracts/generate.js +1 -1
  35. package/dist/dr.js +3 -3
  36. package/dist/{gateway-BQHpV-MZ.d.ts → gateway-CW-61lvs.d.ts} +2 -2
  37. package/dist/gateway-main.js +5 -5
  38. package/dist/gateway.d.ts +3 -3
  39. package/dist/gateway.js +3 -3
  40. package/dist/index.js +7 -7
  41. package/dist/{postgres-bridge-store-WASumj26.d.ts → postgres-bridge-store-C9KG9Z69.d.ts} +1 -1
  42. package/dist/postgres-production-preflight.d.ts +32 -0
  43. package/dist/postgres-production-preflight.js +249 -0
  44. package/dist/postgres-production-preflight.js.map +1 -0
  45. package/dist/server.js +7 -7
  46. package/dist/{sqlite-edge-store-Bc15DvpX.d.ts → sqlite-edge-store-CdMIOrho.d.ts} +1 -1
  47. package/dist/sqlite.d.ts +3 -3
  48. package/dist/sqlite.js +2 -2
  49. package/docs/architecture-v2.md +36 -5
  50. package/docs/deployment.md +144 -0
  51. package/llms.txt +2 -1
  52. package/openapi/agent-bridge-v2.openapi.json +5 -1
  53. package/package.json +5 -1
  54. package/schemas/agent-bridge-v2.capabilities.json +2 -1
  55. package/schemas/agent-bridge-v2.mcp.json +5 -1
  56. package/schemas/agent-bridge-v2.schema.json +4 -0
  57. package/sql/migrations/007_runtime_role.sql +8 -4
  58. package/sql/migrations/013_row_isolation.sql +71 -8
  59. package/sql/migrations/014_owner_control_plane.sql +124 -31
  60. package/sql/migrations/015_portable_archives.sql +143 -41
  61. package/sql/migrations/016_native_dr_fence.sql +102 -35
  62. package/sql/migrations/018_endpoint_migration_challenges.sql +36 -10
  63. package/sql/migrations/019_endpoint_migration_same_successor.sql +34 -10
  64. package/sql/migrations/020_managed_authority_compat.sql +698 -0
  65. package/sql/migrations/021_native_backup_reader.sql +421 -0
  66. package/sql/setup.sql +1 -3
  67. package/dist/chunk-2DFNOLMO.js.map +0 -1
  68. package/dist/chunk-3YHVUPU2.js.map +0 -1
  69. package/dist/chunk-4VB44KJU.js.map +0 -1
  70. package/dist/chunk-6BTJE6D4.js.map +0 -1
  71. package/dist/chunk-IHZFI7TH.js.map +0 -1
  72. package/dist/chunk-PDIHDBP3.js.map +0 -1
  73. package/dist/chunk-SYGONYMM.js.map +0 -1
  74. /package/dist/{chunk-MCKKVNCE.js.map → chunk-QPC6JHA3.js.map} +0 -0
  75. /package/dist/{chunk-SV3SVBYS.js.map → chunk-SN7PIVDF.js.map} +0 -0
  76. /package/dist/{chunk-LAHNQZDN.js.map → chunk-Z6JP6MYD.js.map} +0 -0
@@ -119,6 +119,26 @@ Keep these authorities separate:
119
119
  | Archive operator | Portable PostgreSQL archive operations | Never |
120
120
  | Backup and restore | Native DR backup or restore | Never |
121
121
 
122
+ The schema owner may be a true PostgreSQL superuser or a managed-provider
123
+ administrator with `CREATEROLE` and the database privileges reported by the production
124
+ preflight. `CREATEROLE` is sufficient for migrations. Native backup requires the
125
+ schema owner to be a true superuser or hold `BYPASSRLS`. A restore target validates the
126
+ intentionally suspended restored schema-owner shell through its superuser session. Migrations create internal
127
+ roles with safe defaults and reject unsafe preexisting roles instead of trying to demote
128
+ them. PostgreSQL 16 and newer may store the schema owner's effective admin, inherit,
129
+ and set authority in two physical grant rows. A native DR restore may recreate it as one
130
+ combined bootstrap-superuser grant. Agent Bridge validates both forms and their
131
+ effective authority. The restricted runtime login must never receive either schema-owner
132
+ authority or `BYPASSRLS`.
133
+
134
+ Migration 021 creates a database-derived `NOLOGIN` backup-reader role. It receives
135
+ `SELECT` on Agent Bridge tables and sequences and is granted only to the schema owner.
136
+ This lets a managed schema owner retain `BYPASSRLS` while producing a complete native
137
+ backup. Do not grant the reader to the runtime login, control members, archive members,
138
+ or another external principal. Native backup and restore require this attestation.
139
+ Gateway runtime readiness does not, so an owner-only backup fault cannot stop normal
140
+ gateway traffic.
141
+
122
142
  Use the platform's secret manager or private mounted secret files. Do not put raw
123
143
  passwords, tokens, or complete database URLs in image layers, source control, command
124
144
  arguments, deployment logs, or shared client config. Rotate each authority on its own
@@ -127,6 +147,130 @@ schedule and after any suspected exposure.
127
147
  The runtime database password must contain at least 32 characters. The bootstrap job
128
148
  fails before role creation or alteration when the mounted value is shorter.
129
149
 
150
+ ## Fly.io reference hosting
151
+
152
+ [`deploy/fly.toml`](../deploy/fly.toml) is the maintained Fly gateway contract. It has
153
+ no app name, region, or release command. The operator chooses the account, app, region,
154
+ PostgreSQL service, and public hostname. Fly terminates HTTPS and forwards to port
155
+ 8787. The service keeps at least one `shared-cpu-1x` machine with 512 MB running, checks
156
+ `GET /readyz`, and gives the Node process 30 seconds after SIGTERM to close listeners
157
+ and database pools.
158
+
159
+ Run the repository check before any Fly action:
160
+
161
+ ```bash
162
+ npm run preflight:fly -- --json
163
+ ```
164
+
165
+ The local check verifies Node, the Dockerfile, and the static Fly contract. If the app
166
+ already exists, add its name:
167
+
168
+ ```bash
169
+ npm run preflight:fly -- --app <app> --json
170
+ ```
171
+
172
+ With `--app`, the command invokes only read-only Fly commands. It observes the active
173
+ account, app status, effective config, machines, and secret names. Its JSON includes
174
+ names and check results but never environment-variable values. A passing local check
175
+ does not prove that an external resource exists or that deployment is safe.
176
+
177
+ The long-running Fly machine may receive only the restricted
178
+ `AGENT_BRIDGE_RUNTIME_DATABASE_URL` database authority. Do not give it
179
+ `AGENT_BRIDGE_DATABASE_URL`, `AGENT_BRIDGE_OPERATOR_DATABASE_URL`,
180
+ `AGENT_BRIDGE_RUNTIME_PASSWORD`, a client bearer token, or a Supabase client key. The
181
+ preflight rejects those names. Keep schema-owner, control-operator, archive, and DR
182
+ authority in separate operator jobs.
183
+
184
+ ### Operator order and gates
185
+
186
+ The following actions change external state and require an operator gate. The
187
+ repository does not perform them:
188
+
189
+ 1. Create or select the Fly app and PostgreSQL authority. Keep PostgreSQL private.
190
+ 2. Run the read-only production database preflight before any backup or migration:
191
+
192
+ ```bash
193
+ AGENT_BRIDGE_DATABASE_URL="..." npm run preflight:postgres:production -- --json --require-ssl
194
+ ```
195
+
196
+ The command opens a read-only transaction and reports only nonsecret capability
197
+ and schema facts. `authority.roles` reports migration authority. `authority.native_dr`
198
+ reports whether the source schema owner is a true superuser or has `BYPASSRLS`. A
199
+ `CREATEROLE` role can pass the migration check while failing the native DR check.
200
+ The command also checks supported server majors, migration-ledger drift, derived-role
201
+ collisions, and the legacy import shape. The PostgreSQL 15 through 18 test matrix
202
+ separately applies the full migration plan as both a superuser and a
203
+ managed-provider-style `CREATEROLE` administrator. `--require-ssl` is required for a
204
+ public database endpoint. The preflight cannot prove that later DDL will succeed. For
205
+ a session pooler, it checks the client TLS socket because PostgreSQL reports the
206
+ pooler's separate database connection.
207
+ 3. Take and verify a native DR backup when upgrading an existing authority.
208
+ 4. Run `agent-bridge migrate` once in an isolated job with
209
+ `AGENT_BRIDGE_DATABASE_URL`. Drain old gateways first when the migration notes
210
+ require it.
211
+ 5. Run [`deploy/bootstrap-runtime.sql`](../deploy/bootstrap-runtime.sql) through a
212
+ separate trusted PostgreSQL session. Supply the schema-owner connection and the
213
+ runtime password to that job only. The bootstrap is transactional and changes an
214
+ existing login only when its attributes, ownership, and membership graph already
215
+ match the recorded runtime contract. It never adopts a restored `NOLOGIN` shell.
216
+ 6. Construct the restricted runtime URL, set only
217
+ `AGENT_BRIDGE_RUNTIME_DATABASE_URL` on the Fly app, and run the app-aware read-only
218
+ preflight.
219
+ 7. Deploy the immutable image with `fly deploy --config deploy/fly.toml --app <app>`.
220
+ Require `/readyz` before routing clients.
221
+ 8. Run authenticated capability and status probes, then publish, pull, claim, and
222
+ settle a disposable targeted delivery.
223
+ 9. Move clients only after the gateway proof passes. Preserve every source edge store
224
+ until its outbox is empty.
225
+
226
+ App creation, database provisioning, secret writes, migration, bootstrap, deployment,
227
+ traffic changes, and client cutover are all gated external actions. The preflight is
228
+ read-only and cannot authorize any of them.
229
+
230
+ ### Fly rollback boundary
231
+
232
+ Before migration, stop a failed deployment and keep the prior image and authority. A
233
+ failed image rollout after a compatible migration can return traffic to the prior
234
+ image only if that image accepts the current schema. Never assume that an older image
235
+ can run against newer migrations.
236
+
237
+ After clients publish through the new gateway, preserve both authorities and every
238
+ edge database. Stop the cutover, drain queued outboxes, and reconcile authoritative
239
+ messages before choosing one authority. If schema or data recovery is required,
240
+ restore the verified native DR bundle into a fresh target and switch authority once.
241
+ Do not run the source and restored database as active authorities at the same time.
242
+
243
+ The manual `gateway production proof` GitHub Actions workflow runs this acceptance
244
+ proof against an existing HTTPS gateway. GitHub restricts it to `main`, and every job
245
+ uses the approval-protected `agent-bridge-production-proof` environment. Configure
246
+ `PROOF_SENDER_TOKEN`, `PROOF_RECEIVER_TOKEN`, `PROOF_HOST_SALT`, and `FLY_API_TOKEN` as
247
+ environment secrets. The sender and receiver credentials must bind the requested
248
+ workspace to the dedicated `proof-sender` and `proof-receiver` principals. The
249
+ sender credential needs `messages:write` and `messages:read`. The receiver needs
250
+ `messages:read`, `deliveries:claim`, `deliveries:settle`, and `deliveries:read`.
251
+
252
+ Run the workflow only when the Fly app has exactly one gateway machine. The cycle job
253
+ restarts that machine and queries the Fly Machines API before and after. It fails
254
+ unless the machine ID stays fixed, the runtime `instance_id` changes, the machine
255
+ returns to `started`, and `/readyz` succeeds. This changes production compute and
256
+ requires environment approval. It does not migrate the database or change secrets.
257
+
258
+ The sender first uses an unreachable loopback origin to force a leased publication
259
+ into its private SQLite outbox. It then synchronizes that row to the supplied gateway
260
+ and repeats the publication with the same idempotency key. A separate host finds the
261
+ exact message through `proof-receiver`, claims its delivery, and acknowledges it.
262
+ After the Fly machine cycle, the verifier uses a fresh instance key, edge database,
263
+ and cursor. It reads the immutable message and confirms that the prior delivery is
264
+ still `acked`.
265
+
266
+ Each phase writes an `agent-bridge-production-proof-v1` receipt. The runner rejects
267
+ unknown fields at the phase boundary and checks every receipt before use. Receipts may
268
+ record IDs, timestamps, principal and workspace labels, the gateway origin, boolean
269
+ checks, and salted SHA-256 host evidence. They never record bearer values, database
270
+ URLs, environment values, message content, lease tokens, cursor files, or edge
271
+ databases. Download all four workflow artifacts for the operator record. A passing
272
+ repository test still does not replace a completed external workflow run.
273
+
130
274
  ### Persistent data and backup
131
275
 
132
276
  A database volume or managed PostgreSQL disk keeps runtime state. It does not protect
package/llms.txt CHANGED
@@ -43,7 +43,7 @@ services can call the CLI or HTTPS API or embed the Node library without a host
43
43
  - `agent-bridge doctor`: active named checks; exits 0 ok, 2 degraded, 1 failed. Blocked outbox rows cannot be healthy.
44
44
  - agent-bridge pending
45
45
  - agent-bridge send, inbox, history, acknowledge
46
- - agent-bridge claim, extend, ack, nack, deliveries, delivery-events, cancel, requeue
46
+ - agent-bridge claim [--message-id <uuid>], extend, ack, nack, deliveries, delivery-events, cancel, requeue
47
47
  - agent-bridge sync, watch, join, presence
48
48
  - agent-bridge clients install <runtime> --identity <principal>
49
49
  - agent-bridge clients inspect <runtime> --identity <principal> --instance <stable-key> --backend-config <absolute path>
@@ -131,6 +131,7 @@ one logical authority across restore but does not fence a live clone.
131
131
 
132
132
  Compatibility: post_context, get_context, ack_context.
133
133
  Reliable messaging: send, history, claim, extend, acknowledge, negative_acknowledge, heartbeat, presence, sync.
134
+ Claim selects the next due delivery unless HTTP 2.1, MCP, or CLI supplies one messageId. The exact form atomically selects and maintains only that eligible recipient delivery. HTTP 2.0 rejects the additive field.
134
135
  Publisher delivery intent: `deliveryPolicy` mode `mailbox|leased` with `maxAttempts`, `retryBaseDelayMs`, `retryMaxDelayMs`, `retryJitterRatio`, and optional `notBefore`. MCP delivery controls: `list_deliveries`, `list_delivery_events`, `cancel_delivery`, `requeue_delivery`. Consumer `maxAttempts` and `retryPolicy` are validated but ignored for one compatibility release.
135
136
 
136
137
  Gateway MCP clients sync automatically and expose manual sync for bounded outbox replay and inbox cache refresh. Offline unacknowledged results are degraded candidates with unknown acknowledgement state, not proof that messages are unread or that no remote work exists.
@@ -4776,7 +4776,7 @@
4776
4776
  "/v2/deliveries/claim": {
4777
4777
  "post": {
4778
4778
  "operationId": "claim_delivery",
4779
- "summary": "Atomically claim the next targeted delivery.",
4779
+ "summary": "Atomically claim the next targeted delivery, or one eligible message when requested.",
4780
4780
  "security": [
4781
4781
  {
4782
4782
  "bearerAuth": []
@@ -4810,6 +4810,10 @@
4810
4810
  "leaseMs": {
4811
4811
  "type": "number"
4812
4812
  },
4813
+ "messageId": {
4814
+ "type": "string",
4815
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
4816
+ },
4813
4817
  "maxAttempts": {
4814
4818
  "type": "number"
4815
4819
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@creatornader/agent-bridge",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Durable, pull-first mailbox and work-delivery control plane for AI agents across runtimes, sessions, and machines.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -90,6 +90,10 @@
90
90
  "test:postgres:contract": "vitest run test/postgres_integration.test.ts --maxWorkers=1 --hookTimeout=60000",
91
91
  "test:postgres:preflight": "node scripts/postgres-preflight.mjs",
92
92
  "test:postgres:preflight:full": "node scripts/postgres-preflight.mjs --full",
93
+ "preflight:postgres:production": "npm run build --silent && node dist/postgres-production-preflight.js",
94
+ "preflight:fly": "node scripts/fly-production-preflight.mjs",
95
+ "proof:gateway:production": "node scripts/gateway-production-proof.mjs",
96
+ "test:gateway:production-proof": "vitest run test/gateway-production-proof.test.ts",
93
97
  "test:windows:contract": "vitest run test/private-path.test.ts test/windows-native-smoke.test.ts --maxWorkers=1 --testTimeout=180000 -t \"applies and verifies native Windows file and directory ACLs|recovers an (enrollment|client-operation) subsystem lock\" && vitest run test/client-maintenance.test.ts --maxWorkers=1 --testTimeout=180000",
94
98
  "typecheck": "tsc --noEmit",
95
99
  "contracts:generate": "npm run build --silent && npm run contracts:generate:built",
@@ -747,7 +747,7 @@
747
747
  },
748
748
  {
749
749
  "id": "claim_delivery",
750
- "summary": "Atomically claim the next targeted delivery.",
750
+ "summary": "Atomically claim the next targeted delivery, or one eligible message when requested.",
751
751
  "mcp": {
752
752
  "name": "claim"
753
753
  },
@@ -759,6 +759,7 @@
759
759
  "command": "claim",
760
760
  "options": [
761
761
  "lease-ms",
762
+ "message-id",
762
763
  "max-attempts"
763
764
  ],
764
765
  "outputSchema": {
@@ -633,13 +633,17 @@
633
633
  },
634
634
  {
635
635
  "name": "claim",
636
- "description": "Atomically claim the next targeted delivery.",
636
+ "description": "Atomically claim the next targeted delivery, or one eligible message when requested.",
637
637
  "inputSchema": {
638
638
  "type": "object",
639
639
  "properties": {
640
640
  "leaseMs": {
641
641
  "type": "number"
642
642
  },
643
+ "messageId": {
644
+ "type": "string",
645
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
646
+ },
643
647
  "maxAttempts": {
644
648
  "type": "number"
645
649
  }
@@ -2446,6 +2446,10 @@
2446
2446
  "leaseMs": {
2447
2447
  "type": "number"
2448
2448
  },
2449
+ "messageId": {
2450
+ "type": "string",
2451
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
2452
+ },
2449
2453
  "maxAttempts": {
2450
2454
  "type": "number"
2451
2455
  }
@@ -12,10 +12,14 @@ begin
12
12
  role_name
13
13
  );
14
14
  end if;
15
- execute format(
16
- 'alter role %I nologin nosuperuser nocreatedb nocreaterole noreplication nobypassrls',
17
- role_name
18
- );
15
+ if exists (
16
+ select 1 from pg_roles where rolname=role_name and (
17
+ rolcanlogin or not rolinherit or rolsuper or rolcreatedb or rolcreaterole
18
+ or rolreplication or rolbypassrls or rolconnlimit<>-1
19
+ )
20
+ ) then
21
+ raise exception 'Agent Bridge runtime role has unsafe attributes';
22
+ end if;
19
23
  execute format('revoke all on schema agent_bridge from %I', role_name);
20
24
  execute format('revoke all on all tables in schema agent_bridge from %I', role_name);
21
25
  execute format('revoke all on all sequences in schema agent_bridge from %I', role_name);
@@ -17,11 +17,73 @@ begin
17
17
  role_name
18
18
  );
19
19
  end if;
20
- execute format(
21
- 'alter role %I nologin nosuperuser nocreatedb nocreaterole noreplication nobypassrls',
22
- role_name
23
- );
24
- execute format('grant %I to %I with admin option',role_name,current_user);
20
+ if exists (
21
+ select 1 from pg_roles where rolname=role_name and (
22
+ rolcanlogin or not rolinherit or rolsuper or rolcreatedb or rolcreaterole
23
+ or rolreplication or rolbypassrls or rolconnlimit<>-1
24
+ )
25
+ ) then
26
+ raise exception 'Agent Bridge row-isolation role % has unsafe attributes',role_name;
27
+ end if;
28
+ if current_setting('server_version_num')::integer>=160000 then
29
+ if not exists (
30
+ select 1 from pg_catalog.pg_auth_members membership
31
+ join pg_catalog.pg_roles granted on granted.oid=membership.roleid
32
+ join pg_catalog.pg_roles member on member.oid=membership.member
33
+ where granted.rolname=role_name and member.rolname=current_user
34
+ and membership.admin_option
35
+ ) then
36
+ execute format(
37
+ 'grant %I to %I with admin true,inherit true,set true',role_name,current_user
38
+ );
39
+ elsif not exists (
40
+ select 1 from pg_catalog.pg_auth_members membership
41
+ join pg_catalog.pg_roles granted on granted.oid=membership.roleid
42
+ join pg_catalog.pg_roles member on member.oid=membership.member
43
+ where granted.rolname=role_name and member.rolname=current_user
44
+ and coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true)
45
+ and coalesce((to_jsonb(membership)->>'set_option')::boolean,true)
46
+ ) then
47
+ execute format('grant %I to %I with inherit true,set true',role_name,current_user);
48
+ end if;
49
+ elsif not exists (
50
+ select 1 from pg_catalog.pg_auth_members membership
51
+ join pg_catalog.pg_roles granted on granted.oid=membership.roleid
52
+ join pg_catalog.pg_roles member on member.oid=membership.member
53
+ where granted.rolname=role_name and member.rolname=current_user
54
+ and membership.admin_option
55
+ ) then
56
+ execute format('grant %I to %I with admin option',role_name,current_user);
57
+ end if;
58
+ if not exists (
59
+ select 1 from pg_catalog.pg_auth_members membership
60
+ join pg_catalog.pg_roles granted on granted.oid=membership.roleid
61
+ join pg_catalog.pg_roles member on member.oid=membership.member
62
+ where granted.rolname=role_name and member.rolname=current_user
63
+ and membership.admin_option
64
+ ) or not exists (
65
+ select 1 from pg_catalog.pg_auth_members membership
66
+ join pg_catalog.pg_roles granted on granted.oid=membership.roleid
67
+ join pg_catalog.pg_roles member on member.oid=membership.member
68
+ where granted.rolname=role_name and member.rolname=current_user
69
+ and coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true)
70
+ and coalesce((to_jsonb(membership)->>'set_option')::boolean,true)
71
+ ) or exists (
72
+ select 1 from pg_catalog.pg_auth_members membership
73
+ join pg_catalog.pg_roles granted on granted.oid=membership.roleid
74
+ join pg_catalog.pg_roles member on member.oid=membership.member
75
+ where granted.rolname=role_name and member.rolname=current_user
76
+ and not (
77
+ (membership.grantor=10 and membership.admin_option
78
+ and not coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true)
79
+ and not coalesce((to_jsonb(membership)->>'set_option')::boolean,true))
80
+ or (membership.grantor=member.oid
81
+ and coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true)
82
+ and coalesce((to_jsonb(membership)->>'set_option')::boolean,true))
83
+ )
84
+ ) then
85
+ raise exception 'Agent Bridge row-isolation role % has unsafe owner authority',role_name;
86
+ end if;
25
87
  end loop;
26
88
  end
27
89
  $roles$;
@@ -292,7 +354,8 @@ declare
292
354
  event_writer text := 'agent_bridge_event_writer_' || suffix;
293
355
  table_name text;
294
356
  begin
295
- execute format('grant usage on schema agent_bridge to %I',data_owner);
357
+ execute format('grant usage,create on schema agent_bridge to %I,%I,%I',
358
+ data_owner,context_reader,event_writer);
296
359
  foreach table_name in array array[
297
360
  'messages','receipts','deliveries','delivery_events','agent_instances'
298
361
  ] loop
@@ -301,9 +364,9 @@ begin
301
364
  execute format('alter function agent_bridge.current_request_workspace() owner to %I',context_reader);
302
365
  execute format('alter function agent_bridge.current_request_principal() owner to %I',context_reader);
303
366
  execute format('alter function agent_bridge.record_delivery_event() owner to %I',event_writer);
304
- execute format('grant usage on schema agent_bridge to %I',context_reader);
367
+ execute format('revoke create on schema agent_bridge from %I,%I,%I',
368
+ data_owner,context_reader,event_writer);
305
369
  execute format('grant select on agent_bridge.request_authorities to %I',context_reader);
306
- execute format('grant usage on schema agent_bridge to %I',event_writer);
307
370
  execute format('grant insert on agent_bridge.delivery_events to %I',event_writer);
308
371
  execute format('grant usage on sequence agent_bridge.delivery_events_sequence_seq to %I',event_writer);
309
372
  end
@@ -18,11 +18,73 @@ begin
18
18
  role_name
19
19
  );
20
20
  end if;
21
- execute format(
22
- 'alter role %I nologin nosuperuser nocreatedb nocreaterole noreplication nobypassrls',
23
- role_name
24
- );
25
- execute format('grant %I to %I with admin option',role_name,current_user);
21
+ if exists (
22
+ select 1 from pg_roles where rolname=role_name and (
23
+ rolcanlogin or not rolinherit or rolsuper or rolcreatedb or rolcreaterole
24
+ or rolreplication or rolbypassrls or rolconnlimit<>-1
25
+ )
26
+ ) then
27
+ raise exception 'Agent Bridge control role % has unsafe attributes',role_name;
28
+ end if;
29
+ if current_setting('server_version_num')::integer>=160000 then
30
+ if not exists (
31
+ select 1 from pg_catalog.pg_auth_members membership
32
+ join pg_catalog.pg_roles granted on granted.oid=membership.roleid
33
+ join pg_catalog.pg_roles member on member.oid=membership.member
34
+ where granted.rolname=role_name and member.rolname=current_user
35
+ and membership.admin_option
36
+ ) then
37
+ execute format(
38
+ 'grant %I to %I with admin true,inherit true,set true',role_name,current_user
39
+ );
40
+ elsif not exists (
41
+ select 1 from pg_catalog.pg_auth_members membership
42
+ join pg_catalog.pg_roles granted on granted.oid=membership.roleid
43
+ join pg_catalog.pg_roles member on member.oid=membership.member
44
+ where granted.rolname=role_name and member.rolname=current_user
45
+ and coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true)
46
+ and coalesce((to_jsonb(membership)->>'set_option')::boolean,true)
47
+ ) then
48
+ execute format('grant %I to %I with inherit true,set true',role_name,current_user);
49
+ end if;
50
+ elsif not exists (
51
+ select 1 from pg_catalog.pg_auth_members membership
52
+ join pg_catalog.pg_roles granted on granted.oid=membership.roleid
53
+ join pg_catalog.pg_roles member on member.oid=membership.member
54
+ where granted.rolname=role_name and member.rolname=current_user
55
+ and membership.admin_option
56
+ ) then
57
+ execute format('grant %I to %I with admin option',role_name,current_user);
58
+ end if;
59
+ if not exists (
60
+ select 1 from pg_catalog.pg_auth_members membership
61
+ join pg_catalog.pg_roles granted on granted.oid=membership.roleid
62
+ join pg_catalog.pg_roles member on member.oid=membership.member
63
+ where granted.rolname=role_name and member.rolname=current_user
64
+ and membership.admin_option
65
+ ) or not exists (
66
+ select 1 from pg_catalog.pg_auth_members membership
67
+ join pg_catalog.pg_roles granted on granted.oid=membership.roleid
68
+ join pg_catalog.pg_roles member on member.oid=membership.member
69
+ where granted.rolname=role_name and member.rolname=current_user
70
+ and coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true)
71
+ and coalesce((to_jsonb(membership)->>'set_option')::boolean,true)
72
+ ) or exists (
73
+ select 1 from pg_catalog.pg_auth_members membership
74
+ join pg_catalog.pg_roles granted on granted.oid=membership.roleid
75
+ join pg_catalog.pg_roles member on member.oid=membership.member
76
+ where granted.rolname=role_name and member.rolname=current_user
77
+ and not (
78
+ (membership.grantor=10 and membership.admin_option
79
+ and coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true)
80
+ =coalesce((to_jsonb(membership)->>'set_option')::boolean,true))
81
+ or (membership.grantor=member.oid
82
+ and coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true)
83
+ and coalesce((to_jsonb(membership)->>'set_option')::boolean,true))
84
+ )
85
+ ) then
86
+ raise exception 'Agent Bridge control role % has unsafe owner authority',role_name;
87
+ end if;
26
88
  end loop;
27
89
  end
28
90
  $roles$;
@@ -144,27 +206,38 @@ begin
144
206
  where event.member_role=session_user and event.control_role in ('operator','auditor')
145
207
  order by event.control_role,event.sequence desc
146
208
  ), direct_membership as (
147
- select granted.rolname control_role,membership.admin_option,
148
- coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true) inherit_option,
149
- coalesce((to_jsonb(membership)->>'set_option')::boolean,true) set_option,
150
- grantor.rolname grantor_role
209
+ select granted.rolname control_role,
210
+ bool_or(membership.admin_option) admin_option,
211
+ bool_or(coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true)) inherit_option,
212
+ bool_or(coalesce((to_jsonb(membership)->>'set_option')::boolean,true)) set_option,
213
+ bool_and(case when member.rolname=schema_owner then
214
+ (membership.grantor=10 and membership.admin_option
215
+ and coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true)
216
+ =coalesce((to_jsonb(membership)->>'set_option')::boolean,true))
217
+ or (membership.grantor=member.oid
218
+ and coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true)
219
+ and coalesce((to_jsonb(membership)->>'set_option')::boolean,true))
220
+ else membership.grantor=(select oid from pg_catalog.pg_roles where rolname=schema_owner)
221
+ and not membership.admin_option
222
+ and coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true)
223
+ and coalesce((to_jsonb(membership)->>'set_option')::boolean,true) end) grants_valid
151
224
  from pg_catalog.pg_auth_members membership
152
225
  join pg_catalog.pg_roles granted on granted.oid=membership.roleid
153
226
  join pg_catalog.pg_roles member on member.oid=membership.member
154
- join pg_catalog.pg_roles grantor on grantor.oid=membership.grantor
155
227
  where member.rolname=session_user and granted.rolname in (operator_role,auditor_role)
228
+ group by granted.rolname
156
229
  )
157
230
  select
158
231
  coalesce(bool_or(membership.control_role=operator_role
159
232
  and membership.inherit_option and membership.set_option
160
- and membership.grantor_role=schema_owner and (
233
+ and membership.grants_valid and (
161
234
  (session_user=schema_owner and membership.admin_option)
162
235
  or (latest.control_role='operator' and latest.action='register'
163
236
  and not membership.admin_option)
164
237
  )),false),
165
238
  coalesce(bool_or(membership.control_role=auditor_role
166
239
  and membership.inherit_option and membership.set_option
167
- and membership.grantor_role=schema_owner and (
240
+ and membership.grants_valid and (
168
241
  (session_user=schema_owner and membership.admin_option)
169
242
  or (latest.control_role='auditor' and latest.action='register'
170
243
  and not membership.admin_option)
@@ -937,15 +1010,15 @@ language sql stable set search_path = '' set timezone = 'UTC' as $$
937
1010
  from pg_catalog.pg_roles role where role.rolname in (select role_name from control_roles)
938
1011
  union all
939
1012
  select 'membership',granted.rolname||'->'||member.rolname,
940
- grantor.rolname||':'||membership.admin_option::text||':'||
941
- coalesce(to_jsonb(membership)->>'inherit_option','')||':'||
942
- coalesce(to_jsonb(membership)->>'set_option','')
1013
+ bool_or(membership.admin_option)::text||':'||
1014
+ bool_or(coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true))::text||':'||
1015
+ bool_or(coalesce((to_jsonb(membership)->>'set_option')::boolean,true))::text
943
1016
  from pg_catalog.pg_auth_members membership
944
1017
  join pg_catalog.pg_roles granted on granted.oid=membership.roleid
945
1018
  join pg_catalog.pg_roles member on member.oid=membership.member
946
- join pg_catalog.pg_roles grantor on grantor.oid=membership.grantor
947
1019
  where member.rolname in (select role_name from control_roles)
948
1020
  or granted.rolname=(select owner_role from names)
1021
+ group by granted.rolname,member.rolname
949
1022
  union all
950
1023
  select 'schema',namespace.nspname,pg_catalog.pg_get_userbyid(namespace.nspowner)
951
1024
  from pg_catalog.pg_namespace namespace where namespace.nspname='agent_bridge'
@@ -1106,27 +1179,42 @@ returns boolean language sql stable security definer set search_path = '' as $$
1106
1179
  ), active_registry as (
1107
1180
  select member_role,control_role from latest_registry where action='register'
1108
1181
  ), expected_memberships as (
1109
- select names.owner_role granted_role,names.schema_owner member_role,true admin_option
1182
+ select names.owner_role granted_role,names.schema_owner member_role,
1183
+ true admin_option,true inherit_option,true set_option
1110
1184
  from names
1111
- union all select names.operator_role,names.schema_owner,true from names
1112
- union all select names.auditor_role,names.schema_owner,true from names
1185
+ union all select names.operator_role,names.schema_owner,true,true,true from names
1186
+ union all select names.auditor_role,names.schema_owner,true,true,true from names
1113
1187
  union all
1114
1188
  select case registry.control_role when 'operator' then names.operator_role
1115
- else names.auditor_role end,registry.member_role,false
1189
+ else names.auditor_role end,registry.member_role,false,true,true
1116
1190
  from active_registry registry cross join names
1117
- ), actual_memberships as (
1191
+ ), raw_memberships as (
1118
1192
  select granted.rolname::name granted_role,member.rolname::name member_role,
1119
1193
  membership.admin_option,
1120
1194
  coalesce((to_jsonb(membership)->>'inherit_option')::boolean,true) inherit_option,
1121
1195
  coalesce((to_jsonb(membership)->>'set_option')::boolean,true) set_option,
1122
- grantor.rolname::name grantor_role
1196
+ membership.grantor,member.oid member_oid
1123
1197
  from pg_catalog.pg_auth_members membership
1124
1198
  join pg_catalog.pg_roles granted on granted.oid=membership.roleid
1125
1199
  join pg_catalog.pg_roles member on member.oid=membership.member
1126
- join pg_catalog.pg_roles grantor on grantor.oid=membership.grantor
1127
1200
  where granted.rolname in (
1128
1201
  (select owner_role from names),(select operator_role from names),(select auditor_role from names)
1129
1202
  )
1203
+ ), actual_memberships as (
1204
+ select membership.granted_role,membership.member_role,
1205
+ bool_or(membership.admin_option) admin_option,
1206
+ bool_or(membership.inherit_option) inherit_option,
1207
+ bool_or(membership.set_option) set_option,
1208
+ bool_and(case when membership.member_role=names.schema_owner then
1209
+ (membership.grantor=10 and membership.admin_option
1210
+ and membership.inherit_option=membership.set_option)
1211
+ or (membership.grantor=membership.member_oid
1212
+ and membership.inherit_option and membership.set_option)
1213
+ else membership.grantor=(select oid from pg_catalog.pg_roles where rolname=names.schema_owner)
1214
+ and not membership.admin_option
1215
+ and membership.inherit_option and membership.set_option end) grants_valid
1216
+ from raw_memberships membership cross join names
1217
+ group by membership.granted_role,membership.member_role
1130
1218
  ), authority_closure as (
1131
1219
  select control_role.granted_role,candidate.rolname::name member_role
1132
1220
  from names
@@ -1145,16 +1233,14 @@ returns boolean language sql stable security definer set search_path = '' as $$
1145
1233
  ) from agent_bridge.owner_control_attestations attestation
1146
1234
  where attestation.name='owner-control-v1')
1147
1235
  and not exists (
1148
- (select granted_role,member_role,admin_option from actual_memberships
1149
- except select granted_role,member_role,admin_option from expected_memberships)
1236
+ (select granted_role,member_role,admin_option,inherit_option,set_option from actual_memberships
1237
+ except select granted_role,member_role,admin_option,inherit_option,set_option from expected_memberships)
1150
1238
  union all
1151
- (select granted_role,member_role,admin_option from expected_memberships
1152
- except select granted_role,member_role,admin_option from actual_memberships)
1239
+ (select granted_role,member_role,admin_option,inherit_option,set_option from expected_memberships
1240
+ except select granted_role,member_role,admin_option,inherit_option,set_option from actual_memberships)
1153
1241
  )
1154
1242
  and not exists (
1155
- select 1 from actual_memberships membership,names
1156
- where not membership.inherit_option or not membership.set_option
1157
- or membership.grantor_role<>names.schema_owner
1243
+ select 1 from actual_memberships membership where not membership.grants_valid
1158
1244
  )
1159
1245
  and not exists (
1160
1246
  select 1 from active_registry registry
@@ -1216,6 +1302,7 @@ declare
1216
1302
  membership_record record;
1217
1303
  relation_record record;
1218
1304
  begin
1305
+ execute format('grant usage,create on schema agent_bridge to %I',owner_role);
1219
1306
  execute format('alter table agent_bridge.control_requests owner to %I',owner_role);
1220
1307
  execute format('alter table agent_bridge.control_events owner to %I',owner_role);
1221
1308
  execute format('alter table agent_bridge.owner_control_attestations owner to %I',owner_role);
@@ -1230,6 +1317,7 @@ begin
1230
1317
  'control_revoke_credential(uuid,uuid,text)','owner_control_catalog_definition()',
1231
1318
  'owner_control_plane_ready()'
1232
1319
  ] loop execute format('alter function agent_bridge.%s owner to %I',role_name,owner_role); end loop;
1320
+ execute format('revoke create on schema agent_bridge from %I',owner_role);
1233
1321
 
1234
1322
  for membership_record in
1235
1323
  select granted.rolname granted_name,member.rolname member_name
@@ -1287,7 +1375,7 @@ begin
1287
1375
  execute format('grant usage on schema agent_bridge to %I,%I,%I',owner_role,operator_role,auditor_role);
1288
1376
  execute format('grant select,insert,update(request_id) on agent_bridge.control_requests to %I',owner_role);
1289
1377
  execute format('grant insert on agent_bridge.control_events to %I',owner_role);
1290
- execute format('grant select on agent_bridge.owner_control_attestations to %I',owner_role);
1378
+ execute format('grant select,insert on agent_bridge.owner_control_attestations to %I',owner_role);
1291
1379
  execute format('grant select,insert on agent_bridge.control_membership_events to %I',owner_role);
1292
1380
  execute format('grant usage on sequence agent_bridge.control_events_sequence_seq to %I',owner_role);
1293
1381
  execute format('grant usage on sequence agent_bridge.control_membership_events_sequence_seq to %I',owner_role);
@@ -1306,6 +1394,7 @@ begin
1306
1394
  execute format('grant execute on function agent_bridge.control_revoke_credential(uuid,uuid,text) to %I',operator_role);
1307
1395
  execute format('grant execute on function agent_bridge.control_credential_inventory(text,timestamp with time zone,uuid,integer) to %I,%I',operator_role,auditor_role);
1308
1396
  execute format('grant execute on function agent_bridge.owner_control_plane_ready() to %I',runtime_role);
1397
+ execute format('grant execute on function agent_bridge.owner_control_plane_ready() to %I',current_user);
1309
1398
  execute format('revoke execute on function agent_bridge.replace_credential(uuid,character,text[],text,text,timestamp with time zone,timestamp with time zone,text,uuid) from %I,%I,%I',operator_role,auditor_role,runtime_role);
1310
1399
  execute format('revoke execute on function agent_bridge.revoke_credential(uuid,text,text,uuid) from %I,%I,%I',operator_role,auditor_role,runtime_role);
1311
1400
  foreach role_name in array array['anon','authenticated'] loop
@@ -1632,8 +1721,12 @@ begin
1632
1721
  end
1633
1722
  $preflight$;
1634
1723
 
1724
+ select set_config(
1725
+ 'role','agent_bridge_control_owner_'||substr(md5(current_database()),1,16),false
1726
+ );
1635
1727
  insert into agent_bridge.owner_control_attestations(name,catalog_definition)
1636
1728
  values('owner-control-v1',agent_bridge.owner_control_catalog_definition());
1729
+ reset role;
1637
1730
 
1638
1731
  insert into agent_bridge.schema_migrations(version,name,checksum)
1639
1732
  values(14,'owner_control_plane','__AGENT_BRIDGE_MIGRATION_CHECKSUM__');