@consiliency/agent-board-schema 1.38.0 → 1.41.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.
- package/fixtures/already-applied.json +8 -0
- package/migrations/20260920000100_agent_board_realtime_credential_claim.sql +119 -0
- package/migrations/20260921000100_agent_board_task_event_origin_posture.sql +881 -0
- package/package.json +1 -1
- package/schema-manifest.json +21 -3
- package/src/tooling.test.mjs +25 -2
- package/tests/agent_board_auth_rls.test.sql +8 -3
- package/tests/agent_board_realtime_credential_claim.test.sql +301 -0
- package/tests/agent_board_signer_lifecycle.test.sql +1 -1
- package/tests/agent_board_task_event_origin_posture.test.sql +393 -0
- package/tests/agent_board_task_thread_lock_order.test.sql +10 -6
- package/tests/catalog/p_metadata_parameters.json +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.41.0",
|
|
4
4
|
"description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
|
|
5
5
|
"repository": "https://github.com/Consiliency/message-board.git",
|
|
6
6
|
"type": "module",
|
package/schema-manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "agent_board_schema_manifest.v1",
|
|
3
3
|
"interface": "IF-0-MBPLANE-BUNDLE-1",
|
|
4
4
|
"package": "@consiliency/agent-board-schema",
|
|
5
|
-
"packageVersion": "1.
|
|
5
|
+
"packageVersion": "1.41.0",
|
|
6
6
|
"schemaVersion": 8,
|
|
7
7
|
"minClientVersion": "1.1.0",
|
|
8
8
|
"source": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
-
"migrationCount":
|
|
50
|
-
"aggregateSha256": "
|
|
49
|
+
"migrationCount": 139,
|
|
50
|
+
"aggregateSha256": "0c0daf769baedbae5243a7345d44df09613494c5ea2495b342222a7248dde6d0",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -1281,6 +1281,24 @@
|
|
|
1281
1281
|
"sourceRef": "message-board",
|
|
1282
1282
|
"bytes": 7204,
|
|
1283
1283
|
"sha256": "464ae39acbd96330c5ad1c753fa1204ca290baa0d6f82d7d30d0ead4d2944d37"
|
|
1284
|
+
},
|
|
1285
|
+
{
|
|
1286
|
+
"order": 137,
|
|
1287
|
+
"id": "20260920000100_agent_board_realtime_credential_claim",
|
|
1288
|
+
"filename": "20260920000100_agent_board_realtime_credential_claim.sql",
|
|
1289
|
+
"origin": "message-board-forward",
|
|
1290
|
+
"sourceRef": "message-board",
|
|
1291
|
+
"bytes": 7556,
|
|
1292
|
+
"sha256": "670d6e97ca84ef74ebd10a6ee102c328005a28aa48f8668ba444ce31b714cf02"
|
|
1293
|
+
},
|
|
1294
|
+
{
|
|
1295
|
+
"order": 138,
|
|
1296
|
+
"id": "20260921000100_agent_board_task_event_origin_posture",
|
|
1297
|
+
"filename": "20260921000100_agent_board_task_event_origin_posture.sql",
|
|
1298
|
+
"origin": "message-board-forward",
|
|
1299
|
+
"sourceRef": "message-board",
|
|
1300
|
+
"bytes": 48237,
|
|
1301
|
+
"sha256": "c515e9cd08b73c118de64125da94735979add5d1128d3ff070ff904af77fef81"
|
|
1284
1302
|
}
|
|
1285
1303
|
],
|
|
1286
1304
|
"excludedBridge": {
|
package/src/tooling.test.mjs
CHANGED
|
@@ -38,9 +38,9 @@ describe('manifest integrity', () => {
|
|
|
38
38
|
expect(manifest.schemaVersion).toBe(8);
|
|
39
39
|
expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
|
40
40
|
});
|
|
41
|
-
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 =
|
|
41
|
+
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 139)', () => {
|
|
42
42
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
43
|
-
expect(manifest.migrationCount).toBe(
|
|
43
|
+
expect(manifest.migrationCount).toBe(139);
|
|
44
44
|
// MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
|
|
45
45
|
// schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
|
|
46
46
|
// enrollment-only additive surface (identity_providers registry, enrollment audit,
|
|
@@ -222,6 +222,29 @@ describe('manifest integrity', () => {
|
|
|
222
222
|
// names. Definition-only: CREATE OR REPLACE with the identical signature, grants and
|
|
223
223
|
// COMMENT restated in the same migration.
|
|
224
224
|
'20260919000100_agent_board_signed_write_model_key.sql',
|
|
225
|
+
// #341 RTCREDCLAIM. `current_agent_id()` read the agent_credential_id claim ONLY from
|
|
226
|
+
// `request.jwt.claim.agent_credential_id`, which PostgREST sets and Realtime does not --
|
|
227
|
+
// so on a postgres_changes subscription the claim read NULL, NULL took the permissive
|
|
228
|
+
// branch by design, and the branch requiring a non-revoked credential never executed.
|
|
229
|
+
// Credential revocation did not stop realtime delivery. Measured: a JWT carrying a bogus
|
|
230
|
+
// credential still received its rows in 542ms. The fix is the coalesce shape auth.uid()
|
|
231
|
+
// has always used -- read the per-claim GUC, else `request.jwt.claims ->> ...` -- which is
|
|
232
|
+
// exactly why auth.uid() survived the same gap. Nothing is loosened: absence in BOTH
|
|
233
|
+
// sources still takes the permissive branch. Definition-only: CREATE OR REPLACE with the
|
|
234
|
+
// identical signature, grants and COMMENT restated in the same migration.
|
|
235
|
+
'20260920000100_agent_board_realtime_credential_claim.sql',
|
|
236
|
+
// #288 ORIGINPOSTURE. v_task_tree.verification_status was NULL for every task_event
|
|
237
|
+
// except the two written by a signed wrapper, and NULL cannot be told apart from
|
|
238
|
+
// "evidence was lost", "the writer forgot" and "unsigned on purpose". The class was
|
|
239
|
+
// derived by CALL CLOSURE (a member iff its closure reaches an INSERT INTO task_events):
|
|
240
|
+
// 12 members, where the issue body said 8 and its own triage comment said 5-6 -- neither
|
|
241
|
+
// naming create_tasking_delivery or signed_create_tasking_delivery. task_events gains
|
|
242
|
+
// origin_posture NOT NULL with NO DEFAULT, so a new direct-INSERT writer cannot insert at
|
|
243
|
+
// all; the funnel body moves behind an ungranted _append_task_event whose posture
|
|
244
|
+
// parameter is FIRST and undefaulted, which is what makes "no default" structural --
|
|
245
|
+
// PostgreSQL refuses a default before non-defaulted parameters. Nothing is DROPped: both
|
|
246
|
+
// public signatures stay byte-identical, so ACLs and COMMENTs survive.
|
|
247
|
+
'20260921000100_agent_board_task_event_origin_posture.sql',
|
|
225
248
|
]) {
|
|
226
249
|
expect(names.has(f)).toBe(true);
|
|
227
250
|
}
|
|
@@ -72,10 +72,15 @@ begin
|
|
|
72
72
|
(v_task_a, v_thread_a, v_agent_a, v_agent_a, v_task_a, 'Auth RLS Task A', 'medium', 'distributed'),
|
|
73
73
|
(v_task_b, v_thread_b, v_agent_b, v_agent_b, v_task_b, 'Auth RLS Task B', 'medium', 'distributed');
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
-- #288: origin_posture is NOT NULL with no default, so this raw fixture insert must declare
|
|
76
|
+
-- one like any other writer. It emulates create_task's seq-1 'created' event, so it declares
|
|
77
|
+
-- create_task's own posture rather than a test-only placeholder -- a fixture that labelled
|
|
78
|
+
-- itself differently from the writer it stands in for would make OP-15-style invariants
|
|
79
|
+
-- assert against a shape production never produces.
|
|
80
|
+
insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload, origin_posture)
|
|
76
81
|
values
|
|
77
|
-
(v_task_a, v_thread_a, v_agent_a, 1, 'created', '{}'::jsonb),
|
|
78
|
-
(v_task_b, v_thread_b, v_agent_b, 1, 'created', '{}'::jsonb);
|
|
82
|
+
(v_task_a, v_thread_a, v_agent_a, 1, 'created', '{}'::jsonb, 'unattested:signed-metadata-rpc'),
|
|
83
|
+
(v_task_b, v_thread_b, v_agent_b, 1, 'created', '{}'::jsonb, 'unattested:signed-metadata-rpc');
|
|
79
84
|
|
|
80
85
|
insert into agent_board.messages(id, thread_id, author_agent_id, message_sequence, message_type, body)
|
|
81
86
|
values
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
-- #341 — the credential branch of `current_agent_id()` must run on BOTH claim transports.
|
|
2
|
+
--
|
|
3
|
+
-- WHY THIS FILE EXISTS. `current_agent_id()` read the credential claim from exactly one place,
|
|
4
|
+
-- `request.jwt.claim.agent_credential_id`, which PostgREST sets and Realtime does not. So for
|
|
5
|
+
-- every `postgres_changes` subscriber the claim read NULL, NULL took the permissive branch by
|
|
6
|
+
-- design, and the branch requiring a non-revoked credential never executed. Credential revocation
|
|
7
|
+
-- did not stop realtime delivery.
|
|
8
|
+
--
|
|
9
|
+
-- The defect was invisible to every existing test because they all exercise the PostgREST-shaped
|
|
10
|
+
-- context, where the per-claim GUC IS set. The assertions below therefore separate the two
|
|
11
|
+
-- transports deliberately: the ones that matter set the whole-JSON GUC and NOTHING else, which is
|
|
12
|
+
-- the context a Realtime subscriber actually gets.
|
|
13
|
+
--
|
|
14
|
+
-- WHAT EACH HALF PINS:
|
|
15
|
+
-- * per-claim transport -- pre-existing behaviour, asserted so the fix cannot regress it;
|
|
16
|
+
-- * whole-JSON transport -- the fix. These fail on the pre-#341 body;
|
|
17
|
+
-- * absence in BOTH -- the permissive branch, which callers with no credential depend on.
|
|
18
|
+
-- Asserted positively rather than assumed, because "the fix did not over-deny" is the half a
|
|
19
|
+
-- revocation fix is most likely to get wrong.
|
|
20
|
+
|
|
21
|
+
begin;
|
|
22
|
+
select plan(17);
|
|
23
|
+
|
|
24
|
+
-- ---------------------------------------------------------------------------
|
|
25
|
+
-- Fixture: one auth user, one active agent bound to it, three credentials
|
|
26
|
+
-- (valid / revoked / expired) belonging to that agent.
|
|
27
|
+
-- ---------------------------------------------------------------------------
|
|
28
|
+
insert into auth.users (id, instance_id, aud, role, email, created_at, updated_at)
|
|
29
|
+
values (
|
|
30
|
+
'34100000-0000-4000-8000-000000000001',
|
|
31
|
+
'00000000-0000-0000-0000-000000000000',
|
|
32
|
+
'authenticated', 'authenticated', 'rtcred-subject@example.test', now(), now()
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
insert into agent_board.agents (id, agent_type, handle, display_name, status, metadata, auth_user_id)
|
|
36
|
+
values (
|
|
37
|
+
'34100000-0000-4000-8000-000000000010',
|
|
38
|
+
'agent', 'rtcred-subject', 'Realtime Credential Subject', 'active', '{}'::jsonb,
|
|
39
|
+
'34100000-0000-4000-8000-000000000001'
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
insert into agent_board.agent_credentials
|
|
43
|
+
(id, agent_id, refresh_token_hash, hash_version, credential_label, expires_at, revoked_at)
|
|
44
|
+
values
|
|
45
|
+
('34100000-0000-4000-8000-000000000100', '34100000-0000-4000-8000-000000000010',
|
|
46
|
+
'\x3411'::bytea, 1, 'valid', now() + interval '1 day', null),
|
|
47
|
+
('34100000-0000-4000-8000-000000000200', '34100000-0000-4000-8000-000000000010',
|
|
48
|
+
'\x3412'::bytea, 1, 'revoked', now() + interval '1 day', now() - interval '1 hour'),
|
|
49
|
+
('34100000-0000-4000-8000-000000000300', '34100000-0000-4000-8000-000000000010',
|
|
50
|
+
'\x3413'::bytea, 1, 'expired', now() - interval '1 hour', null);
|
|
51
|
+
|
|
52
|
+
-- ---------------------------------------------------------------------------
|
|
53
|
+
-- A. PER-CLAIM GUC context. NOTE THE LABEL: an earlier version called this "PostgREST-shaped",
|
|
54
|
+
-- which is wrong -- PostgREST removed these GUCs at v12 and sets only request.jwt.claims, so
|
|
55
|
+
-- NO production transport produces this context; only direct SQL / set_config does. Kept
|
|
56
|
+
-- anyway, because it pins the coalesce ORDER and the pre-existing branch semantics.
|
|
57
|
+
-- ---------------------------------------------------------------------------
|
|
58
|
+
select set_config('request.jwt.claim.sub', '34100000-0000-4000-8000-000000000001', true);
|
|
59
|
+
select set_config('request.jwt.claims', '', true);
|
|
60
|
+
select set_config('request.jwt.claim.agent_credential_id', '', true);
|
|
61
|
+
|
|
62
|
+
select is(
|
|
63
|
+
(select agent_board.current_agent_id()),
|
|
64
|
+
'34100000-0000-4000-8000-000000000010'::uuid,
|
|
65
|
+
'A1 per-claim transport, no credential claim: resolves the agent (permissive branch)'
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
select set_config('request.jwt.claim.agent_credential_id', '34100000-0000-4000-8000-000000000100', true);
|
|
69
|
+
select is(
|
|
70
|
+
(select agent_board.current_agent_id()),
|
|
71
|
+
'34100000-0000-4000-8000-000000000010'::uuid,
|
|
72
|
+
'A2 per-claim transport, VALID credential: resolves the agent'
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
select set_config('request.jwt.claim.agent_credential_id', '34100000-0000-4000-8000-000000000200', true);
|
|
76
|
+
select is(
|
|
77
|
+
(select agent_board.current_agent_id()),
|
|
78
|
+
null::uuid,
|
|
79
|
+
'A3 per-claim transport, REVOKED credential: denies'
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
-- ---------------------------------------------------------------------------
|
|
83
|
+
-- B. Realtime-shaped context: ONLY the whole-JSON GUC. These are the fix.
|
|
84
|
+
-- Every per-claim GUC is cleared first, so a pass here cannot be produced by
|
|
85
|
+
-- the source that already worked.
|
|
86
|
+
-- ---------------------------------------------------------------------------
|
|
87
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
88
|
+
select set_config('request.jwt.claim.agent_credential_id', '', true);
|
|
89
|
+
|
|
90
|
+
select set_config(
|
|
91
|
+
'request.jwt.claims',
|
|
92
|
+
'{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated"}',
|
|
93
|
+
true
|
|
94
|
+
);
|
|
95
|
+
select is(
|
|
96
|
+
(select agent_board.current_agent_id()),
|
|
97
|
+
'34100000-0000-4000-8000-000000000010'::uuid,
|
|
98
|
+
'B1 realtime transport, claims carry no credential key: resolves (permissive branch preserved)'
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
select set_config(
|
|
102
|
+
'request.jwt.claims',
|
|
103
|
+
'{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"34100000-0000-4000-8000-000000000100"}',
|
|
104
|
+
true
|
|
105
|
+
);
|
|
106
|
+
select is(
|
|
107
|
+
(select agent_board.current_agent_id()),
|
|
108
|
+
'34100000-0000-4000-8000-000000000010'::uuid,
|
|
109
|
+
'B2 realtime transport, VALID credential in claims: resolves (fix does not over-deny)'
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
select set_config(
|
|
113
|
+
'request.jwt.claims',
|
|
114
|
+
'{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"34100000-0000-4000-8000-000000000200"}',
|
|
115
|
+
true
|
|
116
|
+
);
|
|
117
|
+
select is(
|
|
118
|
+
(select agent_board.current_agent_id()),
|
|
119
|
+
null::uuid,
|
|
120
|
+
'B3 realtime transport, REVOKED credential in claims: denies (the #341 defect)'
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
select set_config(
|
|
124
|
+
'request.jwt.claims',
|
|
125
|
+
'{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"34100000-0000-4000-8000-000000000300"}',
|
|
126
|
+
true
|
|
127
|
+
);
|
|
128
|
+
select is(
|
|
129
|
+
(select agent_board.current_agent_id()),
|
|
130
|
+
null::uuid,
|
|
131
|
+
'B4 realtime transport, EXPIRED credential in claims: denies'
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
select set_config(
|
|
135
|
+
'request.jwt.claims',
|
|
136
|
+
'{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"99999999-9999-4999-8999-999999999999"}',
|
|
137
|
+
true
|
|
138
|
+
);
|
|
139
|
+
select is(
|
|
140
|
+
(select agent_board.current_agent_id()),
|
|
141
|
+
null::uuid,
|
|
142
|
+
'B5 realtime transport, credential belonging to no agent: denies'
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
-- A credential that exists and is valid but belongs to a DIFFERENT agent must not authorize this
|
|
146
|
+
-- one. The nearest principal that should still be refused, rather than a stranger: a stranger is
|
|
147
|
+
-- refused by any check that looks at anything at all.
|
|
148
|
+
insert into auth.users (id, instance_id, aud, role, email, created_at, updated_at)
|
|
149
|
+
values (
|
|
150
|
+
'34100000-0000-4000-8000-000000000002',
|
|
151
|
+
'00000000-0000-0000-0000-000000000000',
|
|
152
|
+
'authenticated', 'authenticated', 'rtcred-other@example.test', now(), now()
|
|
153
|
+
);
|
|
154
|
+
insert into agent_board.agents (id, agent_type, handle, display_name, status, metadata, auth_user_id)
|
|
155
|
+
values (
|
|
156
|
+
'34100000-0000-4000-8000-000000000020',
|
|
157
|
+
'agent', 'rtcred-other', 'Other Agent', 'active', '{}'::jsonb,
|
|
158
|
+
'34100000-0000-4000-8000-000000000002'
|
|
159
|
+
);
|
|
160
|
+
insert into agent_board.agent_credentials
|
|
161
|
+
(id, agent_id, refresh_token_hash, hash_version, credential_label, expires_at, revoked_at)
|
|
162
|
+
values
|
|
163
|
+
('34100000-0000-4000-8000-000000000400', '34100000-0000-4000-8000-000000000020',
|
|
164
|
+
'\x3414'::bytea, 1, 'other-valid', now() + interval '1 day', null);
|
|
165
|
+
|
|
166
|
+
select set_config(
|
|
167
|
+
'request.jwt.claims',
|
|
168
|
+
'{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"34100000-0000-4000-8000-000000000400"}',
|
|
169
|
+
true
|
|
170
|
+
);
|
|
171
|
+
select is(
|
|
172
|
+
(select agent_board.current_agent_id()),
|
|
173
|
+
null::uuid,
|
|
174
|
+
'B6 realtime transport, valid credential owned by ANOTHER agent: denies'
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
-- ---------------------------------------------------------------------------
|
|
178
|
+
-- C. Precedence and shape.
|
|
179
|
+
-- ---------------------------------------------------------------------------
|
|
180
|
+
-- Both sources present: the per-claim value is preferred. Asserted so the coalesce ORDER is
|
|
181
|
+
-- pinned, not merely its membership. A revoked value in the ignored source must not deny.
|
|
182
|
+
select set_config('request.jwt.claim.agent_credential_id', '34100000-0000-4000-8000-000000000100', true);
|
|
183
|
+
select set_config(
|
|
184
|
+
'request.jwt.claims',
|
|
185
|
+
'{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"34100000-0000-4000-8000-000000000200"}',
|
|
186
|
+
true
|
|
187
|
+
);
|
|
188
|
+
select is(
|
|
189
|
+
(select agent_board.current_agent_id()),
|
|
190
|
+
'34100000-0000-4000-8000-000000000010'::uuid,
|
|
191
|
+
'C1 both sources present: per-claim wins over whole-JSON'
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
-- The function must still be SECURITY DEFINER with a pinned search_path. A recreate that dropped
|
|
195
|
+
-- either would change who it can read and what it resolves, silently.
|
|
196
|
+
select is(
|
|
197
|
+
(select p.prosecdef from pg_catalog.pg_proc p
|
|
198
|
+
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
|
199
|
+
where n.nspname = 'agent_board' and p.proname = 'current_agent_id'),
|
|
200
|
+
true,
|
|
201
|
+
'C2 current_agent_id remains SECURITY DEFINER'
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
-- ---------------------------------------------------------------------------
|
|
205
|
+
-- E. MALFORMED CLAIM SHAPES MUST DENY, NOT RAISE.
|
|
206
|
+
--
|
|
207
|
+
-- A correctness seat measured that the UUID regex never guarded the cast: `::uuid` is evaluated
|
|
208
|
+
-- at SQL-function startup, before any qual filtering, so a malformed claim raised 22P02 rather
|
|
209
|
+
-- than denying -- on the OLD body too, which is why this migration removes the cast entirely and
|
|
210
|
+
-- compares as text. Every one of these was RED before that change.
|
|
211
|
+
-- ---------------------------------------------------------------------------
|
|
212
|
+
select set_config('request.jwt.claim.agent_credential_id', '', true);
|
|
213
|
+
|
|
214
|
+
select set_config(
|
|
215
|
+
'request.jwt.claims',
|
|
216
|
+
'{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":""}',
|
|
217
|
+
true
|
|
218
|
+
);
|
|
219
|
+
select is(
|
|
220
|
+
(select agent_board.current_agent_id()),
|
|
221
|
+
'34100000-0000-4000-8000-000000000010'::uuid,
|
|
222
|
+
'E1 EMPTY-STRING claim is ABSENT on the whole-JSON arm too, matching the per-claim arm'
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
select set_config(
|
|
226
|
+
'request.jwt.claims',
|
|
227
|
+
'{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"bogus"}',
|
|
228
|
+
true
|
|
229
|
+
);
|
|
230
|
+
select is(
|
|
231
|
+
(select agent_board.current_agent_id()),
|
|
232
|
+
null::uuid,
|
|
233
|
+
'E2 NON-UUID credential claim denies cleanly instead of raising 22P02'
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
select set_config(
|
|
237
|
+
'request.jwt.claims',
|
|
238
|
+
'{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":42}',
|
|
239
|
+
true
|
|
240
|
+
);
|
|
241
|
+
select is(
|
|
242
|
+
(select agent_board.current_agent_id()),
|
|
243
|
+
null::uuid,
|
|
244
|
+
'E3 NUMERIC credential claim denies cleanly instead of raising 22P02'
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
-- Case-insensitivity is load-bearing now that the comparison is text: uuid::text is canonical
|
|
248
|
+
-- LOWERCASE, so an uppercase claim would silently stop matching without lower().
|
|
249
|
+
select set_config(
|
|
250
|
+
'request.jwt.claims',
|
|
251
|
+
'{"sub":"34100000-0000-4000-8000-000000000001","role":"authenticated","agent_credential_id":"34100000-0000-4000-8000-000000000100"}',
|
|
252
|
+
true
|
|
253
|
+
);
|
|
254
|
+
select is(
|
|
255
|
+
(select agent_board.current_agent_id()),
|
|
256
|
+
'34100000-0000-4000-8000-000000000010'::uuid,
|
|
257
|
+
'E4 UPPERCASE credential claim still matches (uuid::text is lowercase; lower() bridges it)'
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
-- ---------------------------------------------------------------------------
|
|
261
|
+
-- D. THE CLASS. #341 is one site; this asserts it is the ONLY one, and keeps it that way.
|
|
262
|
+
--
|
|
263
|
+
-- A function that reads a per-claim GUC (`request.jwt.claim.<name>`) WITHOUT also reading the
|
|
264
|
+
-- whole-JSON GUC has #341's defect by construction: PostgREST populates the first, Realtime only
|
|
265
|
+
-- the second, so its check silently does not run for subscribers. Catalog-derived, so a NEW
|
|
266
|
+
-- function with that shape fails this and NAMES ITSELF rather than needing anyone to remember.
|
|
267
|
+
--
|
|
268
|
+
-- `keep_literals => true` is REQUIRED and is the trap this repo documents: the GUC name is a
|
|
269
|
+
-- STRING LITERAL, and stripped_body strips literals by default, so the default spelling matches
|
|
270
|
+
-- nothing and the assertion passes vacuously.
|
|
271
|
+
--
|
|
272
|
+
-- Note `request.jwt.claim.` carries a trailing dot, so it does not also match `request.jwt.claims`.
|
|
273
|
+
-- ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
-- Population canary FIRST: an empty "no offenders" result must not be reachable by the class
|
|
276
|
+
-- being empty. If nothing reads a per-claim GUC at all, this file is asserting nothing.
|
|
277
|
+
select cmp_ok(
|
|
278
|
+
(select count(*)
|
|
279
|
+
from pg_catalog.pg_proc p
|
|
280
|
+
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
|
281
|
+
where n.nspname = 'agent_board'
|
|
282
|
+
and agent_board_test.stripped_body(p.oid, true) like '%request.jwt.claim.%'),
|
|
283
|
+
'>=',
|
|
284
|
+
1::bigint,
|
|
285
|
+
'D1 population canary: at least one agent_board function reads a per-claim JWT GUC'
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
select is_empty(
|
|
289
|
+
$$
|
|
290
|
+
select p.proname
|
|
291
|
+
from pg_catalog.pg_proc p
|
|
292
|
+
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
|
293
|
+
where n.nspname = 'agent_board'
|
|
294
|
+
and agent_board_test.stripped_body(p.oid, true) like '%request.jwt.claim.%'
|
|
295
|
+
and agent_board_test.stripped_body(p.oid, true) not like '%request.jwt.claims%'
|
|
296
|
+
$$,
|
|
297
|
+
'D2 no agent_board function reads a per-claim JWT GUC without also reading request.jwt.claims'
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
select finish();
|
|
301
|
+
rollback;
|
|
@@ -511,7 +511,7 @@ select is(
|
|
|
511
511
|
where n.nspname = 'agent_board'
|
|
512
512
|
and p.proname like 'signed\_%'
|
|
513
513
|
and agent_board_test.stripped_body(p.oid)
|
|
514
|
-
~ '\m(send_direct_message|send_multicast_message|post_board_message|append_task_event|create_tasking_delivery)\s*\('),
|
|
514
|
+
~ '\m(send_direct_message|send_multicast_message|post_board_message|_?append_task_event|create_tasking_delivery)\s*\('),
|
|
515
515
|
'signed_append_task_event,signed_create_tasking_delivery,signed_post_board_message,'
|
|
516
516
|
|| 'signed_send_direct_message,signed_send_multicast_message',
|
|
517
517
|
'the five exempt wrappers each perform a message/task write, which is why they are exempt');
|