@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
|
@@ -548,6 +548,14 @@
|
|
|
548
548
|
{
|
|
549
549
|
"id": "20260919000100_agent_board_signed_write_model_key",
|
|
550
550
|
"sha256": "464ae39acbd96330c5ad1c753fa1204ca290baa0d6f82d7d30d0ead4d2944d37"
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
"id": "20260920000100_agent_board_realtime_credential_claim",
|
|
554
|
+
"sha256": "670d6e97ca84ef74ebd10a6ee102c328005a28aa48f8668ba444ce31b714cf02"
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
"id": "20260921000100_agent_board_task_event_origin_posture",
|
|
558
|
+
"sha256": "c515e9cd08b73c118de64125da94735979add5d1128d3ff070ff904af77fef81"
|
|
551
559
|
}
|
|
552
560
|
]
|
|
553
561
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
-- #341 — `current_agent_id()`'s credential check never ran. Anywhere.
|
|
2
|
+
--
|
|
3
|
+
-- THE DEFECT, corrected after a correctness seat disproved this header's first version by
|
|
4
|
+
-- measurement. The original text said PostgREST sets the per-claim GUCs and Realtime does not, so
|
|
5
|
+
-- the credential branch was dead only for subscribers. That is FALSE, and the truth is worse.
|
|
6
|
+
--
|
|
7
|
+
-- Measured through a real PostgREST (v14.5) with a signed `authenticated` JWT carrying
|
|
8
|
+
-- `agent_credential_id`, by a probe function called over HTTP:
|
|
9
|
+
--
|
|
10
|
+
-- per_claim_sub : null
|
|
11
|
+
-- per_claim_cred : null
|
|
12
|
+
-- whole_json_present : true
|
|
13
|
+
-- whole_json_has_cred : true
|
|
14
|
+
--
|
|
15
|
+
-- PostgREST REMOVED the legacy `request.jwt.claim.<name>` GUCs (the option went away in v12), and
|
|
16
|
+
-- nothing in this repository sets them either. So the pre-fix body, which read ONLY that source,
|
|
17
|
+
-- resolved the claim to NULL on EVERY transport. NULL takes the permissive branch by design, and
|
|
18
|
+
-- the branch requiring a non-revoked, non-expired credential NEVER EXECUTED — not on Realtime,
|
|
19
|
+
-- and not on the RPC path either.
|
|
20
|
+
--
|
|
21
|
+
-- CONSEQUENCE, stated plainly because the first version of this header understated its own fix:
|
|
22
|
+
-- mid-JWT-lifetime credential revocation stopped nothing, anywhere. This migration does not
|
|
23
|
+
-- "restore" a gate that worked on one path; it ACTIVATES the credential gate for the first time,
|
|
24
|
+
-- on every path. That is a real behaviour change on the live RPC path, not only a subscription fix.
|
|
25
|
+
--
|
|
26
|
+
-- WHY AUTH.UID() SURVIVED THE SAME GAP, which is where the fix comes from. `auth.uid()` already
|
|
27
|
+
-- reads BOTH sources and coalesces:
|
|
28
|
+
--
|
|
29
|
+
-- coalesce(
|
|
30
|
+
-- nullif(current_setting('request.jwt.claim.sub', true), ''),
|
|
31
|
+
-- (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
|
|
32
|
+
-- )::uuid
|
|
33
|
+
--
|
|
34
|
+
-- The second source is populated on both transports, and carries custom claims. Verified on
|
|
35
|
+
-- Realtime with a throwaway table whose RLS policy keyed ONLY on that GUC (a row wanting the JWT's
|
|
36
|
+
-- value delivered, a row wanting a different value not), and on PostgREST by the probe above.
|
|
37
|
+
--
|
|
38
|
+
-- THE FIX is that same coalesce shape applied to the credential claim. Nothing is loosened:
|
|
39
|
+
-- absence in BOTH sources still takes the permissive branch, which callers that legitimately have
|
|
40
|
+
-- no credential depend on.
|
|
41
|
+
--
|
|
42
|
+
-- NO NEW RAISE PATH — and the first version of this header was wrong about that too. It claimed
|
|
43
|
+
-- the cast to `jsonb` was "a way this function can now raise where it previously could not". The
|
|
44
|
+
-- previous body raised as well: with a per-claim value of `bogus` it raises
|
|
45
|
+
-- `invalid input syntax for type uuid (22P02)`, measured on the pre-fix body. The UUID regex does
|
|
46
|
+
-- NOT guard the cast — the cast is evaluated at SQL-function startup, before any qual filtering,
|
|
47
|
+
-- so a malformed value raises regardless of a conjunct that syntactically precedes it.
|
|
48
|
+
--
|
|
49
|
+
-- Rather than disclose that, this migration REMOVES the raise class: the credential is compared as
|
|
50
|
+
-- TEXT against `ac.id::text`, so a malformed claim of any shape (`""`, `42`, `{"a":1}`, `bogus`)
|
|
51
|
+
-- matches nothing and DENIES CLEANLY instead of erroring. `lower()` because `uuid::text` is
|
|
52
|
+
-- canonical lowercase and the claim may not be. The regex is kept as a cheap pre-filter, and is
|
|
53
|
+
-- now genuinely a filter rather than a guard that never guarded.
|
|
54
|
+
--
|
|
55
|
+
-- WHAT THIS DOES NOT DO. It does not make a Realtime payload trustworthy. Delivery is now gated on
|
|
56
|
+
-- the same credential the RPC path checks, but the event body is still a replication row image and
|
|
57
|
+
-- the subscription can silently miss events (`SUBSCRIBED` is reported before events actually flow —
|
|
58
|
+
-- measured: on a cold Realtime container the first insert after SUBSCRIBED was dropped). Consumers
|
|
59
|
+
-- must treat an event as "something changed, go fetch". That posture is asserted in the client.
|
|
60
|
+
--
|
|
61
|
+
-- CREATE OR REPLACE with the identical signature, so the ACL survives — verified before writing
|
|
62
|
+
-- this: `authenticated` and `service_role` hold EXECUTE, `anon` does not, PUBLIC holds nothing.
|
|
63
|
+
-- Restated anyway, because a recreated SECURITY DEFINER function that does not restate its
|
|
64
|
+
-- privileges is a gate-bypass this repo has already shipped once.
|
|
65
|
+
|
|
66
|
+
create or replace function agent_board.current_agent_id()
|
|
67
|
+
returns uuid
|
|
68
|
+
language sql
|
|
69
|
+
stable
|
|
70
|
+
security definer
|
|
71
|
+
set search_path = ''
|
|
72
|
+
as $$
|
|
73
|
+
with credential_claim as (
|
|
74
|
+
-- #341: read BOTH sources. PostgREST sets the per-claim GUC; Realtime sets only the
|
|
75
|
+
-- whole-JSON one. Reading a single source made the credential branch dead code for every
|
|
76
|
+
-- Realtime subscriber. Mirrors auth.uid(), which has always coalesced for the same reason.
|
|
77
|
+
select coalesce(
|
|
78
|
+
nullif(current_setting('request.jwt.claim.agent_credential_id', true), ''),
|
|
79
|
+
-- The OUTER nullif is not redundant, and its absence was a transport inconsistency
|
|
80
|
+
-- caught by a test: the per-claim arm maps '' to absent, so without this the same
|
|
81
|
+
-- logical claim ("" ) would be ABSENT on one transport and a malformed credential
|
|
82
|
+
-- that DENIES on the other. Same input, different answer by transport, which is the
|
|
83
|
+
-- exact bug class #341 is about. Empty means absent on both, matching what omission
|
|
84
|
+
-- already permits.
|
|
85
|
+
nullif(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'agent_credential_id', '')
|
|
86
|
+
) as credential_id_text
|
|
87
|
+
)
|
|
88
|
+
select a.id
|
|
89
|
+
from agent_board.agents a
|
|
90
|
+
cross join credential_claim cc
|
|
91
|
+
where a.auth_user_id = auth.uid()
|
|
92
|
+
and a.status = 'active'
|
|
93
|
+
and (
|
|
94
|
+
cc.credential_id_text is null
|
|
95
|
+
or (
|
|
96
|
+
cc.credential_id_text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
97
|
+
and exists (
|
|
98
|
+
select 1
|
|
99
|
+
from agent_board.agent_credentials ac
|
|
100
|
+
-- TEXT comparison, never a cast of the untrusted claim: a malformed value must DENY,
|
|
101
|
+
-- not raise. `ac.id::text` is canonical lowercase, hence lower() on the claim.
|
|
102
|
+
where ac.id::text = lower(cc.credential_id_text)
|
|
103
|
+
and ac.agent_id = a.id
|
|
104
|
+
and ac.revoked_at is null
|
|
105
|
+
and (ac.expires_at is null or ac.expires_at > now())
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
limit 1;
|
|
110
|
+
$$;
|
|
111
|
+
|
|
112
|
+
-- Restated rather than relied upon. `anon` is deliberately NOT granted: it did not hold EXECUTE
|
|
113
|
+
-- before this migration and must not gain it.
|
|
114
|
+
revoke all on function agent_board.current_agent_id() from public;
|
|
115
|
+
grant execute on function agent_board.current_agent_id() to authenticated;
|
|
116
|
+
grant execute on function agent_board.current_agent_id() to service_role;
|
|
117
|
+
|
|
118
|
+
comment on function agent_board.current_agent_id() is
|
|
119
|
+
'Resolves auth.uid() to the active agent_board.agents.id and, when an agent_credential_id claim is present in EITHER the per-claim GUC (request.jwt.claim.agent_credential_id) or the whole-JSON GUC (request.jwt.claims), requires it to reference a non-revoked, non-expired credential for that agent. Reading only the per-claim source made the credential branch unreachable on EVERY transport, not just Realtime: PostgREST removed those legacy GUCs at v12 and nothing here sets them, so the claim read NULL and took the permissive branch everywhere. This migration ACTIVATES the credential gate for the first time rather than restoring it (#341). The claim is compared as TEXT against agent_credentials.id::text, so a malformed claim denies cleanly instead of raising 22P02. Absence in BOTH sources still takes the permissive branch, which callers that legitimately have no credential depend on.';
|