@consiliency/agent-board-schema 1.60.0 → 1.64.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/README.md +86 -0
- package/fixtures/already-applied.json +16 -0
- package/migrations/20261011000100_agent_board_session_identities.sql +1964 -0
- package/migrations/20261012000100_agent_board_sidowner.sql +209 -0
- package/migrations/20261014000100_agent_board_grant_target_role.sql +218 -0
- package/migrations/20261014000200_agent_board_last_owner.sql +239 -0
- package/package.json +1 -1
- package/schema-manifest.json +39 -3
- package/src/tooling.test.mjs +10 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +15 -4
- package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +7 -0
- package/tests/agent_board_consent_emission.test.sql +30 -11
- package/tests/agent_board_consent_gating.test.sql +16 -2
- package/tests/agent_board_delegate_role_class.test.sql +1 -1
- package/tests/agent_board_delegation_cap_ceiling.test.sql +26 -15
- package/tests/agent_board_delegation_enforcement.test.sql +1 -1
- package/tests/agent_board_enroll_resumption.test.sql +22 -4
- package/tests/agent_board_enroll_token_single_use.test.sql +18 -3
- package/tests/agent_board_federated_lock_order_class.test.sql +2 -2
- package/tests/agent_board_fk_lock_edges.test.sql +77 -16
- package/tests/agent_board_grant_target_role.test.sql +1118 -0
- package/tests/agent_board_hosted_delegates.test.sql +5 -3
- package/tests/agent_board_invite_no_downgrade.test.sql +15 -11
- package/tests/agent_board_ledger_authority.test.sql +77 -12
- package/tests/agent_board_membership_current_row_class.test.sql +9 -1
- package/tests/agent_board_mint_fleet_agent_session.test.sql +2 -2
- package/tests/agent_board_mint_harden.test.sql +38 -27
- package/tests/agent_board_role_guard.test.sql +319 -0
- package/tests/agent_board_rolereset.test.sql +25 -29
- package/tests/agent_board_roomdeleg.test.sql +8 -0
- package/tests/agent_board_roomdeleg_lane_b.test.sql +6 -6
- package/tests/agent_board_roomown.test.sql +3 -1
- package/tests/agent_board_rowid.test.sql +7 -2
- package/tests/agent_board_session_identity_approval_revocation.test.sql +334 -0
- package/tests/agent_board_session_identity_backfill.test.sql +82 -0
- package/tests/agent_board_session_identity_backfill_refusal.test.sql +14 -0
- package/tests/agent_board_session_identity_behavior_classes.test.sql +176 -0
- package/tests/agent_board_session_identity_class.test.sql +118 -0
- package/tests/agent_board_session_identity_fingerprint.test.sql +271 -0
- package/tests/agent_board_session_identity_labels_limits.test.sql +205 -0
- package/tests/agent_board_session_identity_lease.test.sql +235 -0
- package/tests/agent_board_session_identity_lifetime.test.sql +230 -0
- package/tests/agent_board_session_identity_limits.test.sql +208 -0
- package/tests/agent_board_session_identity_lineage.test.sql +64 -0
- package/tests/agent_board_session_identity_link.test.sql +201 -0
- package/tests/agent_board_session_identity_mint.test.sql +233 -0
- package/tests/agent_board_session_identity_panel_r1.test.sql +194 -0
- package/tests/agent_board_session_identity_panel_r2.test.sql +174 -0
- package/tests/agent_board_session_identity_protected_actions.test.sql +156 -0
- package/tests/agent_board_session_identity_revoke_closure.test.sql +131 -0
- package/tests/agent_board_sidowner_leases.test.sql +107 -0
- package/tests/agent_board_sidowner_owner_required.test.sql +97 -0
- package/tests/agent_board_sidowner_read_class.test.sql +171 -0
- package/tests/agent_board_sidowner_reads.test.sql +136 -0
- package/tests/agent_board_signer_lifecycle.test.sql +26 -4
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +1 -1
- package/tests/agent_board_write_enforcement_class.test.sql +74 -15
- package/tests/catalog/function_execute_privileges.json +227 -2
- package/tests/catalog/p_metadata_parameters.json +102 -2
- package/tests/isolation/expected/grant_target_role_race.out +348 -0
- package/tests/isolation/expected/last_owner_departure.out +562 -0
- package/tests/isolation/expected/session_identity_limits.out +165 -0
- package/tests/isolation/expected/session_identity_links.out +235 -0
- package/tests/isolation/expected/session_identity_reclaim.out +253 -0
- package/tests/isolation/expected/session_identity_renew_revoke.out +115 -0
- package/tests/isolation/expected/session_identity_renew_revoke_key_gate.out +138 -0
- package/tests/isolation/expected/session_identity_tombstone_onboarding.out +247 -0
- package/tests/isolation/specs/atomic_fleet_authority_races.spec +8 -3
- package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +0 -3
- package/tests/isolation/specs/atomic_fleet_board_archive.spec +0 -3
- package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +0 -3
- package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +0 -3
- package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +0 -3
- package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +0 -3
- package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +0 -3
- package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +0 -2
- package/tests/isolation/specs/claimpolicy_board_scope.spec +0 -2
- package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +0 -2
- package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +0 -2
- package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +0 -2
- package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +0 -2
- package/tests/isolation/specs/consent_approval_lock.spec +0 -1
- package/tests/isolation/specs/consent_emit_actual_state.spec +0 -1
- package/tests/isolation/specs/grant_target_role_race.spec +206 -0
- package/tests/isolation/specs/hosted_delegate_add_tombstone_race.spec +0 -1
- package/tests/isolation/specs/hosted_delegate_tombstone_revoke_race.spec +0 -1
- package/tests/isolation/specs/hosted_delegate_withdraw_evict_race.spec +0 -1
- package/tests/isolation/specs/last_owner_departure.spec +137 -0
- package/tests/isolation/specs/ledger_authority_revoke.spec +0 -3
- package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +0 -3
- package/tests/isolation/specs/outbox_wake_ordering.spec +0 -1
- package/tests/isolation/specs/session_identity_limits.spec +81 -0
- package/tests/isolation/specs/session_identity_links.spec +82 -0
- package/tests/isolation/specs/session_identity_reclaim.spec +125 -0
- package/tests/isolation/specs/session_identity_renew_revoke.spec +84 -0
- package/tests/isolation/specs/session_identity_renew_revoke_key_gate.spec +94 -0
- package/tests/isolation/specs/session_identity_tombstone_onboarding.spec +107 -0
- package/tests/isolation/specs/signed_grant_onboarding_deadlock.spec +0 -1
- package/tests/isolation/specs/signer_lifecycle_revoke.spec +0 -1
- package/tests/seeds/20261011000100_agent_board_session_identities.sql.pre.sql +47 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
-- SIDOWNER: safe, scoped session-identity inspection. These reads deliberately
|
|
2
|
+
-- expose only the normalized state an owner/ancestor needs to diagnose and approve.
|
|
3
|
+
|
|
4
|
+
create function agent_board._session_identity_read_allowed(p_identity_agent_id uuid)
|
|
5
|
+
returns boolean
|
|
6
|
+
language sql
|
|
7
|
+
stable
|
|
8
|
+
security definer
|
|
9
|
+
set search_path = ''
|
|
10
|
+
as $$
|
|
11
|
+
with recursive lineage(agent_id, parent_agent_id) as (
|
|
12
|
+
select si.agent_id, si.parent_agent_id
|
|
13
|
+
from agent_board.session_identities si
|
|
14
|
+
where si.agent_id = p_identity_agent_id
|
|
15
|
+
union all
|
|
16
|
+
select parent.agent_id, parent.parent_agent_id
|
|
17
|
+
from agent_board.session_identities parent
|
|
18
|
+
join lineage child on child.parent_agent_id = parent.agent_id
|
|
19
|
+
)
|
|
20
|
+
select agent_board.current_agent_id() is not null
|
|
21
|
+
and (agent_board._session_identity_owner(p_identity_agent_id)
|
|
22
|
+
or exists (
|
|
23
|
+
select 1 from lineage where agent_id = agent_board.current_agent_id()
|
|
24
|
+
));
|
|
25
|
+
$$;
|
|
26
|
+
revoke all on function agent_board._session_identity_read_allowed(uuid) from public, anon, authenticated;
|
|
27
|
+
grant execute on function agent_board._session_identity_read_allowed(uuid) to service_role;
|
|
28
|
+
comment on function agent_board._session_identity_read_allowed(uuid) is 'Internal SIDOWNER read predicate. A verified active human root owner, the selected identity, or its strict lineage ancestor may inspect its normalized identity state. It never treats a sibling or another root as an ancestor.';
|
|
29
|
+
|
|
30
|
+
create function agent_board.get_session_identity_state(p_identity_agent_id uuid)
|
|
31
|
+
returns table(
|
|
32
|
+
agent_id uuid,
|
|
33
|
+
parent_agent_id uuid,
|
|
34
|
+
root_agent_id uuid,
|
|
35
|
+
handle text,
|
|
36
|
+
depth smallint,
|
|
37
|
+
harness text,
|
|
38
|
+
lease_state text,
|
|
39
|
+
resolution_state text,
|
|
40
|
+
fingerprint_resolution text,
|
|
41
|
+
current_risky_digest text,
|
|
42
|
+
approved_risky_digest text,
|
|
43
|
+
recorded_fingerprint jsonb,
|
|
44
|
+
tombstoned_at timestamptz,
|
|
45
|
+
tombstone_reason text
|
|
46
|
+
)
|
|
47
|
+
language plpgsql
|
|
48
|
+
stable
|
|
49
|
+
security definer
|
|
50
|
+
set search_path = ''
|
|
51
|
+
as $$
|
|
52
|
+
begin
|
|
53
|
+
if not agent_board._session_identity_read_allowed(p_identity_agent_id) then
|
|
54
|
+
raise exception 'identity_read_forbidden' using errcode = '42501';
|
|
55
|
+
end if;
|
|
56
|
+
|
|
57
|
+
return query
|
|
58
|
+
select si.agent_id,
|
|
59
|
+
si.parent_agent_id,
|
|
60
|
+
si.root_agent_id,
|
|
61
|
+
a.handle,
|
|
62
|
+
si.depth,
|
|
63
|
+
si.harness,
|
|
64
|
+
case
|
|
65
|
+
when si.identity_kind <> 'session' then 'not_applicable'
|
|
66
|
+
when si.holder_secret_hash is null then 'unclaimed'
|
|
67
|
+
when agent_board._session_identity_lease_live(si.agent_id) then 'live'
|
|
68
|
+
else 'lapsed'
|
|
69
|
+
end,
|
|
70
|
+
si.resolution_state,
|
|
71
|
+
coalesce(si.recorded_fingerprint ->> 'resolution', 'unresolved'),
|
|
72
|
+
si.current_risky_digest,
|
|
73
|
+
si.approved_risky_digest,
|
|
74
|
+
si.recorded_fingerprint,
|
|
75
|
+
si.tombstoned_at,
|
|
76
|
+
si.tombstone_reason
|
|
77
|
+
from agent_board.session_identities si
|
|
78
|
+
join agent_board.agents a on a.id = si.agent_id
|
|
79
|
+
where si.agent_id = p_identity_agent_id;
|
|
80
|
+
end;
|
|
81
|
+
$$;
|
|
82
|
+
revoke all on function agent_board.get_session_identity_state(uuid) from public, anon;
|
|
83
|
+
grant execute on function agent_board.get_session_identity_state(uuid) to authenticated, service_role;
|
|
84
|
+
comment on function agent_board.get_session_identity_state(uuid) is 'SIDOWNER normalized safe identity-state read. The active human root owner, selected identity, or a strict lineage ancestor may inspect one identity, including tombstones. Missing and inaccessible identities both refuse identity_read_forbidden; no credential epochs, aliases, holder hashes, keys, or credentials are projected.';
|
|
85
|
+
|
|
86
|
+
create function agent_board.list_session_children(p_parent_agent_id uuid)
|
|
87
|
+
returns table(
|
|
88
|
+
agent_id uuid,
|
|
89
|
+
parent_agent_id uuid,
|
|
90
|
+
root_agent_id uuid,
|
|
91
|
+
handle text,
|
|
92
|
+
depth smallint,
|
|
93
|
+
harness text,
|
|
94
|
+
lease_state text,
|
|
95
|
+
resolution_state text,
|
|
96
|
+
fingerprint_resolution text,
|
|
97
|
+
current_risky_digest text,
|
|
98
|
+
approved_risky_digest text,
|
|
99
|
+
recorded_fingerprint jsonb,
|
|
100
|
+
tombstoned_at timestamptz,
|
|
101
|
+
tombstone_reason text
|
|
102
|
+
)
|
|
103
|
+
language plpgsql
|
|
104
|
+
stable
|
|
105
|
+
security definer
|
|
106
|
+
set search_path = ''
|
|
107
|
+
as $$
|
|
108
|
+
begin
|
|
109
|
+
if not agent_board._session_identity_read_allowed(p_parent_agent_id) then
|
|
110
|
+
raise exception 'identity_read_forbidden' using errcode = '42501';
|
|
111
|
+
end if;
|
|
112
|
+
|
|
113
|
+
return query
|
|
114
|
+
with recursive descendants(agent_id) as (
|
|
115
|
+
select child.agent_id
|
|
116
|
+
from agent_board.session_identities child
|
|
117
|
+
where child.parent_agent_id = p_parent_agent_id
|
|
118
|
+
union all
|
|
119
|
+
select child.agent_id
|
|
120
|
+
from agent_board.session_identities child
|
|
121
|
+
join descendants parent on child.parent_agent_id = parent.agent_id
|
|
122
|
+
)
|
|
123
|
+
select state.agent_id,
|
|
124
|
+
state.parent_agent_id,
|
|
125
|
+
state.root_agent_id,
|
|
126
|
+
state.handle,
|
|
127
|
+
state.depth,
|
|
128
|
+
state.harness,
|
|
129
|
+
state.lease_state,
|
|
130
|
+
state.resolution_state,
|
|
131
|
+
state.fingerprint_resolution,
|
|
132
|
+
state.current_risky_digest,
|
|
133
|
+
state.approved_risky_digest,
|
|
134
|
+
state.recorded_fingerprint,
|
|
135
|
+
state.tombstoned_at,
|
|
136
|
+
state.tombstone_reason
|
|
137
|
+
from descendants d
|
|
138
|
+
cross join lateral agent_board.get_session_identity_state(d.agent_id) state
|
|
139
|
+
order by state.depth, state.agent_id;
|
|
140
|
+
end;
|
|
141
|
+
$$;
|
|
142
|
+
revoke all on function agent_board.list_session_children(uuid) from public, anon;
|
|
143
|
+
grant execute on function agent_board.list_session_children(uuid) to authenticated, service_role;
|
|
144
|
+
comment on function agent_board.list_session_children(uuid) is 'SIDOWNER scoped strict-lineage descendant list. The parent is excluded; only parent_agent_id edges are traversed, never forked_from. Authorized callers receive the normalized safe projection for depth-one and depth-two descendants, including tombstones, ordered by depth then identity.';
|
|
145
|
+
|
|
146
|
+
create function agent_board._session_identity_has_human_owner(p_root_agent_id uuid)
|
|
147
|
+
returns boolean
|
|
148
|
+
language sql
|
|
149
|
+
stable
|
|
150
|
+
security definer
|
|
151
|
+
set search_path = ''
|
|
152
|
+
as $$
|
|
153
|
+
select exists (
|
|
154
|
+
select 1 from agent_board.agents root
|
|
155
|
+
join agent_board.agents human on human.auth_user_id = root.owner_user_id
|
|
156
|
+
where root.id = p_root_agent_id
|
|
157
|
+
and human.agent_type = 'human'
|
|
158
|
+
and human.status = 'active'
|
|
159
|
+
);
|
|
160
|
+
$$;
|
|
161
|
+
revoke all on function agent_board._session_identity_has_human_owner(uuid) from public, anon, authenticated;
|
|
162
|
+
grant execute on function agent_board._session_identity_has_human_owner(uuid) to service_role;
|
|
163
|
+
comment on function agent_board._session_identity_has_human_owner(uuid) is 'Internal N1 root-owner existence predicate, not acting-human authorization: the root owner_user_id must bind an active human agent, matching the human-existence requirement of _session_identity_owner.';
|
|
164
|
+
|
|
165
|
+
create or replace function agent_board.record_session_identity_state(p_identity_agent_id uuid, p_fingerprint jsonb)
|
|
166
|
+
returns void language plpgsql security definer set search_path = '' as $$
|
|
167
|
+
declare v_identity agent_board.session_identities%rowtype;
|
|
168
|
+
v_risky text := nullif(p_fingerprint ->> 'risky_digest', '');
|
|
169
|
+
v_recorded jsonb := coalesce(p_fingerprint -> 'recorded', '{}'::jsonb)
|
|
170
|
+
|| jsonb_build_object('resolution', coalesce(p_fingerprint ->> 'resolution', 'unresolved'));
|
|
171
|
+
begin
|
|
172
|
+
if not agent_board._is_service_role() then
|
|
173
|
+
raise exception 'identity reading is service_role-only' using errcode = '42501';
|
|
174
|
+
end if;
|
|
175
|
+
if coalesce(p_fingerprint ->> 'resolution', 'unresolved') not in ('resolved', 'unresolved') then
|
|
176
|
+
raise exception 'invalid identity fingerprint resolution' using errcode = '22023';
|
|
177
|
+
end if;
|
|
178
|
+
perform 1 from agent_board.agents where id = (
|
|
179
|
+
select root_agent_id from agent_board.session_identities where agent_id = p_identity_agent_id
|
|
180
|
+
) for update;
|
|
181
|
+
select * into v_identity from agent_board.session_identities where agent_id = p_identity_agent_id for update;
|
|
182
|
+
if not found then raise exception 'identity not found' using errcode = 'P0002'; end if;
|
|
183
|
+
if v_identity.tombstoned_at is not null then raise exception 'identity_tombstoned' using errcode = '42501'; end if;
|
|
184
|
+
if not exists (select 1 from agent_board.agents a where a.id = p_identity_agent_id and a.auth_user_id = auth.uid() and a.status = 'active')
|
|
185
|
+
or (v_identity.identity_kind = 'session' and agent_board.current_agent_id() is distinct from p_identity_agent_id) then
|
|
186
|
+
raise exception 'identity_lease_not_held' using errcode = '42501';
|
|
187
|
+
end if;
|
|
188
|
+
if v_identity.depth = 0 and not agent_board._session_identity_has_human_owner(v_identity.root_agent_id) then
|
|
189
|
+
raise exception 'identity_human_owner_required' using errcode = '42501';
|
|
190
|
+
end if;
|
|
191
|
+
if jsonb_typeof(coalesce(p_fingerprint, '{}'::jsonb)) <> 'object' or jsonb_typeof(v_recorded) <> 'object' then
|
|
192
|
+
raise exception 'invalid identity fingerprint reading' using errcode = '22023';
|
|
193
|
+
end if;
|
|
194
|
+
-- Return normally: throwing here would roll back the freeze. SIDSERVER must
|
|
195
|
+
-- commit this measured reading before invoking a protected action separately.
|
|
196
|
+
update agent_board.session_identities
|
|
197
|
+
set current_risky_digest = v_risky, recorded_fingerprint = v_recorded,
|
|
198
|
+
resolution_state = case
|
|
199
|
+
when p_fingerprint ->> 'resolution' is distinct from 'resolved' or v_risky is null
|
|
200
|
+
or v_identity.approved_risky_digest is null then 'frozen'
|
|
201
|
+
when v_risky is distinct from v_identity.approved_risky_digest or v_identity.resolution_state = 'frozen' then 'frozen'
|
|
202
|
+
else 'active' end,
|
|
203
|
+
updated_at = now()
|
|
204
|
+
where agent_id = p_identity_agent_id;
|
|
205
|
+
end;
|
|
206
|
+
$$;
|
|
207
|
+
revoke all on function agent_board.record_session_identity_state(uuid,jsonb) from public, anon, authenticated;
|
|
208
|
+
grant execute on function agent_board.record_session_identity_state(uuid,jsonb) to service_role;
|
|
209
|
+
comment on function agent_board.record_session_identity_state(uuid,jsonb) is 'Service-only fingerprint recording bound to verified subject and epoch, with root-before-identity locking. N1 refuses every depth-zero identity without a bound active human owner before any reading or freeze is stored. Other trusted session recording, durable freeze, explicit approval and lease-preservation semantics remain unchanged; no public body attestation is accepted.';
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
-- #674 (SECURITY): grant_board_role must check the TARGET's current role.
|
|
2
|
+
--
|
|
3
|
+
-- DEFECT. agent_board.grant_board_role authorized a grant by the NEW role only, through
|
|
4
|
+
-- _grant_within_ceiling(grantor, board, new_role). It never read the target's CURRENT role, so
|
|
5
|
+
-- any active member -- an observer included -- could demote every owner and admin of a board,
|
|
6
|
+
-- and an admin could then remove them (update_board_membership refuses an admin removing an
|
|
7
|
+
-- owner directly, but not an admin removing a member it had just demoted), leaving the board
|
|
8
|
+
-- with no owner. Evidence: message-board-ui contracts/evidence/membership-semantics.md, C.2-C.5,
|
|
9
|
+
-- C.7, E.1, E.3, E.4b, E.4c.
|
|
10
|
+
--
|
|
11
|
+
-- OWNER DECISIONS (2026-10-14, plans/plan-674-grant-target-role.md D0):
|
|
12
|
+
-- 1. deploy 1.63.0 and publish it to npm (with the 1.61.1/1.62.0 SID migrations);
|
|
13
|
+
-- 2. a rolled-back production probe is allowed;
|
|
14
|
+
-- 3. only owners and admins may change anyone ELSE's role.
|
|
15
|
+
--
|
|
16
|
+
-- THE RULE (D1-D9 in brief). Two checks, only when the grantor is a different AGENT ID from the
|
|
17
|
+
-- target ("self" is the same agent id, not the same human):
|
|
18
|
+
-- C-A (decision 3): grantor rank < admin -> 42501 'grant on another member requires owner or admin role'
|
|
19
|
+
-- C-B (#674): grantor rank <= target's CURRENT rank
|
|
20
|
+
-- -> 42501 'grant requires a higher board role than the target'
|
|
21
|
+
-- Ranks are _role_rank's (owner 4, admin 3, member 2, observer 1, null/other 0), so a missing
|
|
22
|
+
-- grantor row fails closed. Equal rank is refused for every rank: an owner cannot act on another
|
|
23
|
+
-- owner (D3; symmetric with update_board_membership), an admin cannot act on another admin (D4),
|
|
24
|
+
-- and a retry of a promotion to the grantor's own rank refuses. Any role may still lower ITSELF
|
|
25
|
+
-- (D2); the unchanged ceiling bounds a self-grant to keep-or-lower. service_role keeps its
|
|
26
|
+
-- existing supplied-grantor trust boundary (D8): the checks run relative to the grantor it
|
|
27
|
+
-- names. The signed route inherits by construction. Delegates are unchanged (no EXECUTE; a
|
|
28
|
+
-- delegate target is refused at step 2). boards.role_gate_enforced does not interact.
|
|
29
|
+
-- #675 (ownerless boards through SELF-exit) stays separate and is not made worse (D9).
|
|
30
|
+
--
|
|
31
|
+
-- ORDER (D5). Steps 1-5 unchanged (role literal, delegate target, board advisory lock, resolved
|
|
32
|
+
-- actor, fleet gate). Step 6 is now ONE statement: the target's active row locked
|
|
33
|
+
-- FOR NO KEY UPDATE OF t -- the mode the UPDATE already takes, so the lock mode and set are
|
|
34
|
+
-- unchanged and the lock is only taken earlier -- LEFT JOINed to the grantor's active row
|
|
35
|
+
-- (unlocked). Step 7 the ceiling, unchanged. Step 8 C-A then C-B. Step 9 the UPDATE, unchanged.
|
|
36
|
+
-- Every existing refusal keeps its code, message and precedence: P0002, then the ceiling, then
|
|
37
|
+
-- the new checks. Lock order stays advisory -> agents(SHARE) -> board_memberships.
|
|
38
|
+
--
|
|
39
|
+
-- SOURCE RULE (R1, AGENTS.md rule 5). The body is pg_get_functiondef of
|
|
40
|
+
-- agent_board.grant_board_role(uuid,uuid,text,uuid) on a fresh replay of this tree (160
|
|
41
|
+
-- migrations; md5(prosrc) 038bad38eae94604312e38bc74a7782c), which is byte-identical to the
|
|
42
|
+
-- live definition in 20261008000100_agent_board_hosted_delegates.sql:1893-1983, plus ONLY the
|
|
43
|
+
-- #674-marked edits. It is NOT the body at 20260909000100:480, which predates the HOSTED
|
|
44
|
+
-- delegate-target refusal; copying that one would silently revert it.
|
|
45
|
+
--
|
|
46
|
+
-- EC-1 (rule 1), run on that replay: pg_proc over every namespace except pg_catalog,
|
|
47
|
+
-- information_schema, pg_toast, pg_temp* and the test helpers, subject
|
|
48
|
+
-- agent_board_test.stripped_body(oid, true) (literals kept, so dynamic SQL counts), pattern
|
|
49
|
+
-- agent_board_test.call_re('grant_board_role'), plus every view's stripped_viewdef: exactly
|
|
50
|
+
-- {agent_board.signed_grant_board_role}; zero views. agent_board_grant_target_role.test.sql
|
|
51
|
+
-- re-asserts it.
|
|
52
|
+
--
|
|
53
|
+
-- DEFINITION-ONLY: every top-level statement is `create or replace function`, `revoke`,
|
|
54
|
+
-- `grant` or `comment`. No data is altered; past demotions made through the defect are not
|
|
55
|
+
-- undone. Rollback posture: forward_corrective_migration -- repinning to 1.62.0 does NOT revert
|
|
56
|
+
-- this, and restoring the old body would re-open the hole.
|
|
57
|
+
--
|
|
58
|
+
-- Same signature and return type, so CREATE OR REPLACE (not DROP). The ACL and the COMMENT are
|
|
59
|
+
-- restated anyway, identical in ACL to 20260909000100:591-592.
|
|
60
|
+
|
|
61
|
+
CREATE OR REPLACE FUNCTION agent_board.grant_board_role(p_board_id uuid, p_agent_id uuid, p_grantee_role text, p_grantor_agent_id uuid DEFAULT NULL::uuid)
|
|
62
|
+
RETURNS TABLE(board_id uuid, agent_id uuid, role text, updated_at timestamp with time zone)
|
|
63
|
+
LANGUAGE plpgsql
|
|
64
|
+
SECURITY DEFINER
|
|
65
|
+
SET search_path TO 'agent_board', 'public'
|
|
66
|
+
AS $function$
|
|
67
|
+
declare
|
|
68
|
+
v_grantor uuid;
|
|
69
|
+
v_updated agent_board.board_memberships%rowtype;
|
|
70
|
+
-- #674: the target's and the grantor's CURRENT active roles, read in one statement (step 6).
|
|
71
|
+
v_target_role text;
|
|
72
|
+
v_grantor_role text;
|
|
73
|
+
begin
|
|
74
|
+
if p_grantee_role not in ('owner', 'admin', 'member', 'observer') then
|
|
75
|
+
raise exception 'invalid grantee role' using errcode = '22023';
|
|
76
|
+
end if;
|
|
77
|
+
|
|
78
|
+
-- #276, FORCED BY AN EXISTING CLASS ASSERTION, not chosen. Adding the ledger gate makes this
|
|
79
|
+
-- function row-lock `agents` (transitively, through _assert_actor_not_fleet_revoked) as well
|
|
80
|
+
-- as board_memberships, which puts it in the class agent_board_signer_lifecycle.test.sql
|
|
81
|
+
-- section 9 pins from the call-graph closure: every function locking BOTH relations must take
|
|
82
|
+
-- the board advisory lock that serialises them against onboard_federated_fleet_agent. That
|
|
83
|
+
-- assertion FAILED and named this function when the gate was added without this line.
|
|
84
|
+
--
|
|
85
|
+
-- The tempting reply is that this function's order is agents -> board_memberships, the SAME
|
|
86
|
+
-- direction as onboarding, so it cannot close the F4 cycle. That is true today and is exactly
|
|
87
|
+
-- the ordering argument #269 refused: mutual exclusion survives a lock being added downstream,
|
|
88
|
+
-- a re-derived ordering does not. So the lock is taken rather than the assertion relaxed.
|
|
89
|
+
--
|
|
90
|
+
-- It also closes a gap 20260901000110 left open and explicitly declined to determine: this
|
|
91
|
+
-- function reads the ceiling for authorization (via _grant_within_ceiling below) and was one
|
|
92
|
+
-- of the three readers that did NOT take the lock serialising the ceiling row's ABSENCE, so a
|
|
93
|
+
-- concurrent first-ceiling insert could be missed and the read pass as default-owner. It is
|
|
94
|
+
-- now serialised, and agent_board_atomic_fleet_onboarding.test.sql's ceiling-reader registry
|
|
95
|
+
-- records it as such.
|
|
96
|
+
--
|
|
97
|
+
-- FIRST, before any row lock, so both routes are advisory -> agents -> board_memberships.
|
|
98
|
+
-- Re-taking it inside signed_grant_board_role (which already holds it) is a no-op: advisory
|
|
99
|
+
-- transaction locks are re-entrant within a session.
|
|
100
|
+
-- HOSTED (role-writer class): a hosted delegate is a member, never higher.
|
|
101
|
+
perform agent_board._refuse_delegate_membership_target(p_agent_id, 'grant_board_role');
|
|
102
|
+
|
|
103
|
+
perform agent_board._lock_board_authority_ceiling(p_board_id);
|
|
104
|
+
|
|
105
|
+
-- Grantor is the RESOLVED actor. service_role -> supplied arg; authenticated ->
|
|
106
|
+
-- current_agent_id() and raises 42501 if null (suspended/unmapped) or if the
|
|
107
|
+
-- supplied arg <> current_agent_id(). This closes the single-principal escalation.
|
|
108
|
+
v_grantor := agent_board._resolved_actor_agent_id(p_grantor_agent_id, 'p_grantor_agent_id');
|
|
109
|
+
|
|
110
|
+
-- #276. Fleet revocation removes board authority, not only send. Sited HERE — inside the
|
|
111
|
+
-- chokepoint, not in the signed wrapper — because signed_grant_board_role is the only other
|
|
112
|
+
-- referrer of this function in the whole database, so the signed route inherits this check
|
|
113
|
+
-- by construction and the unsigned `authenticated` route (the wider door) is closed by the
|
|
114
|
+
-- same line. Before the membership pre-check so the lock order is agents -> board_memberships,
|
|
115
|
+
-- matching onboard_federated_fleet_agent; below the write it would be the inversion #269
|
|
116
|
+
-- reproduced as `deadlock detected`, and this route has no advisory lock to be rescued by.
|
|
117
|
+
perform agent_board._assert_actor_not_fleet_revoked(v_grantor, 'grant_board_role');
|
|
118
|
+
|
|
119
|
+
-- #674 step 6. ONE statement replaces the bare EXISTS pre-check: the target's active row,
|
|
120
|
+
-- row-locked in NO KEY UPDATE mode, on t only (the mode the UPDATE below takes, only earlier),
|
|
121
|
+
-- LEFT JOINed to the grantor's active row, which is NOT locked. Its snapshot is taken after
|
|
122
|
+
-- the board advisory-lock wait above. After a row-lock wait, READ COMMITTED re-fetches only
|
|
123
|
+
-- the locked row t; the joined grantor row g keeps the statement snapshot's version. That is
|
|
124
|
+
-- benign (plan D5): every grant on this board and link_session_alias are excluded by the
|
|
125
|
+
-- advisory lock, and the one unserialized writer that can change an active grantor's role
|
|
126
|
+
-- (invite-link redeem's rank-max) only RAISES it, so a stale grantor rank is conservative.
|
|
127
|
+
-- A later change (#675's hook) must not rely on g being fresh after this statement.
|
|
128
|
+
-- Both reads are single-row by the partial unique index on active memberships.
|
|
129
|
+
select t.role, g.role
|
|
130
|
+
into v_target_role, v_grantor_role
|
|
131
|
+
from agent_board.board_memberships t
|
|
132
|
+
left join agent_board.board_memberships g
|
|
133
|
+
on g.board_id = t.board_id
|
|
134
|
+
and g.agent_id = v_grantor
|
|
135
|
+
and g.membership_state in ('joined', 'subscribed')
|
|
136
|
+
where t.board_id = p_board_id
|
|
137
|
+
and t.agent_id = p_agent_id
|
|
138
|
+
and t.membership_state in ('joined', 'subscribed')
|
|
139
|
+
for no key update of t;
|
|
140
|
+
if not found then
|
|
141
|
+
raise exception 'grantee has no membership on this board' using errcode = 'P0002';
|
|
142
|
+
end if;
|
|
143
|
+
|
|
144
|
+
-- Ceiling consumption: grantee-bounded against min(grantor role, board ceiling).
|
|
145
|
+
if not agent_board._grant_within_ceiling(v_grantor, p_board_id, p_grantee_role) then
|
|
146
|
+
raise exception 'grant exceeds grantor role or board authority ceiling' using errcode = '42501';
|
|
147
|
+
end if;
|
|
148
|
+
|
|
149
|
+
-- #674 step 8. Two checks for a grantor that is NOT the target (self = the same agent id,
|
|
150
|
+
-- not the same human). C-A (owner decision 3): only an owner or admin may change anyone
|
|
151
|
+
-- else's role. C-B (#674): the grantor must STRICTLY outrank the target's current role, so
|
|
152
|
+
-- equal rank is refused for every rank (owner -> owner, admin -> admin). Both read the roles
|
|
153
|
+
-- step 6 observed. A null role ranks 0, so a missing grantor row fails closed. C-A runs
|
|
154
|
+
-- before C-B; the ceiling above runs before both. Self-grants are exempt from both, and the
|
|
155
|
+
-- ceiling still bounds a self-grant to keep-or-lower.
|
|
156
|
+
if v_grantor is distinct from p_agent_id then
|
|
157
|
+
if agent_board._role_rank(v_grantor_role) < agent_board._role_rank('admin') then
|
|
158
|
+
raise exception 'grant on another member requires owner or admin role' using errcode = '42501';
|
|
159
|
+
end if;
|
|
160
|
+
if agent_board._role_rank(v_grantor_role) <= agent_board._role_rank(v_target_role) then
|
|
161
|
+
raise exception 'grant requires a higher board role than the target' using errcode = '42501';
|
|
162
|
+
end if;
|
|
163
|
+
end if;
|
|
164
|
+
|
|
165
|
+
update agent_board.board_memberships bm
|
|
166
|
+
set role = p_grantee_role,
|
|
167
|
+
updated_at = now()
|
|
168
|
+
where bm.board_id = p_board_id
|
|
169
|
+
and bm.agent_id = p_agent_id
|
|
170
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
171
|
+
returning * into v_updated;
|
|
172
|
+
|
|
173
|
+
-- CR finding: the state predicate above closes the unauthorized WRITE, but on its own it
|
|
174
|
+
-- CREATES a false-success path. If an eviction commits between the pre-check and this
|
|
175
|
+
-- UPDATE (the TOCTOU window the mirrored predicate exists for), the UPDATE matches zero
|
|
176
|
+
-- rows, v_updated stays NULL, and the RETURN QUERY below would emit ONE ALL-NULL ROW with
|
|
177
|
+
-- no error -- a caller that checks "did I get a row back?" reads that as a completed grant.
|
|
178
|
+
-- Fail closed with the same P0002 the pre-check raises, so the serial and racing cases are
|
|
179
|
+
-- indistinguishable to the caller.
|
|
180
|
+
if not found then
|
|
181
|
+
raise exception 'grantee has no membership on this board' using errcode = 'P0002';
|
|
182
|
+
end if;
|
|
183
|
+
|
|
184
|
+
return query
|
|
185
|
+
select v_updated.board_id, v_updated.agent_id, v_updated.role, v_updated.updated_at;
|
|
186
|
+
end;
|
|
187
|
+
$function$;
|
|
188
|
+
|
|
189
|
+
revoke all on function agent_board.grant_board_role(uuid, uuid, text, uuid) from public, anon;
|
|
190
|
+
grant execute on function agent_board.grant_board_role(uuid, uuid, text, uuid) to authenticated, service_role;
|
|
191
|
+
|
|
192
|
+
comment on function agent_board.grant_board_role(uuid, uuid, text, uuid) is
|
|
193
|
+
'Live board-role grant chokepoint. Grantor is the resolved actor (_resolved_actor_agent_id: '
|
|
194
|
+
'service_role uses the supplied arg, authenticated uses current_agent_id() and fails closed on '
|
|
195
|
+
'null/mismatch). #276: the resolved grantor is then checked against the fleet revocation ledger '
|
|
196
|
+
'by _assert_actor_not_fleet_revoked -- an agent carrying an agent-subject fleet_revocation_events '
|
|
197
|
+
'row cannot complete a grant (42501, ''fleet_revoked:''), which is the same answer '
|
|
198
|
+
'evaluate_fleet_sender_policy already gives that principal on the send path. The check sits in '
|
|
199
|
+
'THIS function rather than in signed_grant_board_role because signed_grant_board_role is the only '
|
|
200
|
+
'other referrer of it in the database, so the signed route inherits the gate and the wider '
|
|
201
|
+
'unsigned authenticated route is closed by the same line; it is placed before the membership '
|
|
202
|
+
'pre-check so the lock order is agents(FOR SHARE) -> board_memberships, matching '
|
|
203
|
+
'onboard_federated_fleet_agent. A principal with no fleet binding has no such row and is '
|
|
204
|
+
'unaffected. Then requires an ACTIVE grantee membership (joined|subscribed; P0002 otherwise) -- '
|
|
205
|
+
'predicate on both the pre-check and the UPDATE (TOCTOU). Then enforces '
|
|
206
|
+
'_grant_within_ceiling(grantor, board, grantee_role) before setting board_memberships.role. '
|
|
207
|
+
'ROLERESET (#79): non-active rows cannot be re-armed with an elevated role. '
|
|
208
|
+
'#674: the membership pre-check is ONE statement that locks the target''s active row FOR NO KEY '
|
|
209
|
+
'UPDATE OF t (the mode the UPDATE takes, only earlier; lock order unchanged: advisory -> '
|
|
210
|
+
'agents(SHARE) -> board_memberships) and LEFT JOINs the grantor''s active row, unlocked. After '
|
|
211
|
+
'the unchanged ceiling check, a grantor that is not the target (self = the same agent id, not '
|
|
212
|
+
'the same human) must be an owner or admin (C-A, 42501 ''grant on another member requires owner '
|
|
213
|
+
'or admin role'') and must strictly outrank the target''s CURRENT role (C-B, 42501 ''grant '
|
|
214
|
+
'requires a higher board role than the target''), so equal rank is refused for every rank. A '
|
|
215
|
+
'self-grant is exempt from both and stays bounded to keep-or-lower by the ceiling. For '
|
|
216
|
+
'service_role the checks run relative to the grantor the caller SUPPLIES -- the existing '
|
|
217
|
+
'operator-trust boundary, which #674 does not change (passing p_grantor_agent_id = p_agent_id '
|
|
218
|
+
'takes the self-exemption).';
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
-- Consiliency/message-board#675: row-level last-active-owner invariant, schema 1.64.0.
|
|
2
|
+
-- Main's grant_board_role body, ACL and COMMENT remain unchanged (schema 1.63.0).
|
|
3
|
+
-- The trigger is refusal-only: it never assigns NEW or clamps a role. The
|
|
4
|
+
-- 20260802000100 / 20260803000100 prohibition concerns a role-changing clamp
|
|
5
|
+
-- that would erase intentional owner/admin seeds; this UPDATE/DELETE guard
|
|
6
|
+
-- leaves all allowed writes and INSERT seeders unchanged.
|
|
7
|
+
|
|
8
|
+
CREATE OR REPLACE FUNCTION agent_board.update_board_membership(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
9
|
+
RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamp with time zone, updated_at timestamp with time zone)
|
|
10
|
+
LANGUAGE plpgsql
|
|
11
|
+
SECURITY DEFINER
|
|
12
|
+
SET search_path TO 'agent_board', 'public'
|
|
13
|
+
AS $function$
|
|
14
|
+
declare
|
|
15
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
16
|
+
v_board agent_board.boards%rowtype;
|
|
17
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
18
|
+
v_is_service boolean := agent_board._is_service_role();
|
|
19
|
+
v_existing_state text;
|
|
20
|
+
v_target_role text;
|
|
21
|
+
v_actor_role text;
|
|
22
|
+
-- HOSTED (D7): the caller is the target hosted delegate's principal asking for `left`.
|
|
23
|
+
v_principal_withdraw boolean;
|
|
24
|
+
begin
|
|
25
|
+
if v_actor is null and not v_is_service then
|
|
26
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
27
|
+
end if;
|
|
28
|
+
|
|
29
|
+
-- Board authority lock first, before the target row and the sink's FK locks.
|
|
30
|
+
perform agent_board._lock_board_authority_ceiling(p_board_id);
|
|
31
|
+
|
|
32
|
+
v_principal_withdraw := not v_is_service
|
|
33
|
+
and v_actor <> p_agent_id
|
|
34
|
+
and p_membership_state = 'left'
|
|
35
|
+
and v_actor = agent_board._delegate_principal_agent_id(p_agent_id);
|
|
36
|
+
|
|
37
|
+
if not v_is_service and v_actor <> p_agent_id and not v_principal_withdraw then
|
|
38
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
39
|
+
if v_board.id is null then
|
|
40
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
41
|
+
end if;
|
|
42
|
+
select bm.role
|
|
43
|
+
into v_actor_role
|
|
44
|
+
from agent_board.board_memberships bm
|
|
45
|
+
where bm.board_id = p_board_id
|
|
46
|
+
and bm.agent_id = v_actor
|
|
47
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
48
|
+
limit 1;
|
|
49
|
+
if v_actor_role is null or v_actor_role not in ('owner', 'admin') then
|
|
50
|
+
raise exception 'board membership change requires owner or admin role'
|
|
51
|
+
using errcode = '42501';
|
|
52
|
+
end if;
|
|
53
|
+
end if;
|
|
54
|
+
|
|
55
|
+
-- HOSTED: LOCKED, so every transition rule below is decided on the state the sink will
|
|
56
|
+
-- overwrite. Unlocked, an admin's uncommitted `removed` committed after this read, and the D7
|
|
57
|
+
-- withdrawal (and an ordinary self-leave, the same branch shape) then wrote `left` over it in
|
|
58
|
+
-- the sink, erasing the eviction R-LINK-7 reads (hosted_delegate_withdraw_evict_race). The
|
|
59
|
+
-- sink's own first lock is this same row in the same order, so the lock set is unchanged;
|
|
60
|
+
-- it is only taken earlier, before reads that lock nothing.
|
|
61
|
+
select bm.membership_state, bm.role
|
|
62
|
+
into v_existing_state, v_target_role
|
|
63
|
+
from agent_board.board_memberships bm
|
|
64
|
+
where bm.board_id = p_board_id
|
|
65
|
+
and bm.agent_id = p_agent_id
|
|
66
|
+
order by
|
|
67
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
68
|
+
bm.created_at desc,
|
|
69
|
+
bm.id desc
|
|
70
|
+
limit 1
|
|
71
|
+
for update;
|
|
72
|
+
if v_existing_state is null then
|
|
73
|
+
raise exception 'board membership not found' using errcode = 'P0002';
|
|
74
|
+
end if;
|
|
75
|
+
|
|
76
|
+
if v_principal_withdraw then
|
|
77
|
+
-- HOSTED (D7): the owner withdraws its own delegate whatever its board role, but ONLY from
|
|
78
|
+
-- the states a self-leave may leave. Never from `removed`: turning an eviction into `left`
|
|
79
|
+
-- would erase the standing R-LINK-7 reads and let the owner re-add the evicted delegate.
|
|
80
|
+
if v_existing_state not in ('joined', 'subscribed', 'invited', 'requested') then
|
|
81
|
+
raise exception 'a principal may withdraw its delegate only from an active or pending membership'
|
|
82
|
+
using errcode = '42501';
|
|
83
|
+
end if;
|
|
84
|
+
elsif not v_is_service then
|
|
85
|
+
if v_actor = p_agent_id then
|
|
86
|
+
if not (
|
|
87
|
+
(p_membership_state = 'left'
|
|
88
|
+
and v_existing_state in ('joined', 'subscribed', 'invited', 'requested'))
|
|
89
|
+
or (p_membership_state in ('joined', 'subscribed')
|
|
90
|
+
and v_existing_state in ('joined', 'subscribed'))
|
|
91
|
+
or (p_membership_state = v_existing_state
|
|
92
|
+
and p_membership_state in ('left', 'removed'))
|
|
93
|
+
) then
|
|
94
|
+
raise exception 'self membership change not permitted; use the join/approval path'
|
|
95
|
+
using errcode = '42501';
|
|
96
|
+
end if;
|
|
97
|
+
else
|
|
98
|
+
if agent_board._role_rank(v_actor_role)
|
|
99
|
+
<= agent_board._role_rank(
|
|
100
|
+
case
|
|
101
|
+
when p_membership_state in ('joined', 'subscribed')
|
|
102
|
+
and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
|
|
103
|
+
then 'member'
|
|
104
|
+
else coalesce(v_target_role, '')
|
|
105
|
+
end) then
|
|
106
|
+
raise exception 'board membership change requires a higher board role than the target'
|
|
107
|
+
using errcode = '42501';
|
|
108
|
+
end if;
|
|
109
|
+
|
|
110
|
+
if p_membership_state in ('joined', 'subscribed')
|
|
111
|
+
and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
|
|
112
|
+
and not agent_board._membership_consent_present(p_board_id, p_agent_id) then
|
|
113
|
+
raise exception 'admitting % requires an act by that agent', p_agent_id
|
|
114
|
+
using errcode = '42501';
|
|
115
|
+
end if;
|
|
116
|
+
|
|
117
|
+
-- INVITEPOLICY (#90): seating an inbound-disclosure membership ('invited'/'requested')
|
|
118
|
+
-- for a target that has blocked THIS actor is refused, mirroring invite_board_member.
|
|
119
|
+
-- Without this, an owner/admin re-seats the exact row the block refuses. Active-admission
|
|
120
|
+
-- states are already consent-gated above; service_role/self never reach this branch.
|
|
121
|
+
if p_membership_state in ('invited', 'requested')
|
|
122
|
+
and not agent_board._invite_permitted(p_board_id, p_agent_id, v_actor) then
|
|
123
|
+
raise exception 'target has blocked invitations from this inviter on this board'
|
|
124
|
+
using errcode = '42501';
|
|
125
|
+
end if;
|
|
126
|
+
end if;
|
|
127
|
+
end if;
|
|
128
|
+
|
|
129
|
+
if not v_is_service
|
|
130
|
+
and p_membership_state = v_existing_state
|
|
131
|
+
and p_membership_state in ('left', 'removed') then
|
|
132
|
+
return query
|
|
133
|
+
select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
|
|
134
|
+
from agent_board.board_memberships bm
|
|
135
|
+
where bm.board_id = p_board_id
|
|
136
|
+
and bm.agent_id = p_agent_id
|
|
137
|
+
order by
|
|
138
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
139
|
+
bm.created_at desc,
|
|
140
|
+
bm.id desc
|
|
141
|
+
limit 1;
|
|
142
|
+
return;
|
|
143
|
+
end if;
|
|
144
|
+
|
|
145
|
+
select * into v_membership
|
|
146
|
+
from agent_board._upsert_board_membership_state(
|
|
147
|
+
p_board_id,
|
|
148
|
+
p_agent_id,
|
|
149
|
+
p_membership_state,
|
|
150
|
+
null,
|
|
151
|
+
p_metadata
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return query
|
|
155
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
|
|
156
|
+
end;
|
|
157
|
+
$function$;
|
|
158
|
+
|
|
159
|
+
-- Consiliency/message-board#675: enforce at the table so every RPC, raw writer,
|
|
160
|
+
-- DELETE and FK cascade has the same invariant. Existing ownerless boards remain usable.
|
|
161
|
+
create or replace function agent_board._board_memberships_last_owner_guard()
|
|
162
|
+
returns trigger
|
|
163
|
+
language plpgsql
|
|
164
|
+
security definer
|
|
165
|
+
set search_path = ''
|
|
166
|
+
as $function$
|
|
167
|
+
declare
|
|
168
|
+
v_key bigint := pg_catalog.hashtextextended(
|
|
169
|
+
'agent_board.board_authority_ceilings:' || old.board_id::text, 0);
|
|
170
|
+
begin
|
|
171
|
+
if old.role <> 'owner' or old.membership_state not in ('joined', 'subscribed') then
|
|
172
|
+
if tg_op = 'DELETE' then return old; else return new; end if;
|
|
173
|
+
end if;
|
|
174
|
+
if tg_op = 'UPDATE' then
|
|
175
|
+
if new.board_id = old.board_id and new.agent_id = old.agent_id and new.role = 'owner'
|
|
176
|
+
and new.membership_state in ('joined', 'subscribed') then
|
|
177
|
+
return new;
|
|
178
|
+
end if;
|
|
179
|
+
end if;
|
|
180
|
+
-- Deleting the board itself is not leaving an existing board ownerless.
|
|
181
|
+
if not exists (select 1 from agent_board.boards where id = old.board_id) then
|
|
182
|
+
if tg_op = 'DELETE' then return old; else return new; end if;
|
|
183
|
+
end if;
|
|
184
|
+
-- A transaction snapshot does not advance after an advisory-lock wait.
|
|
185
|
+
if current_setting('transaction_isolation') <> 'read committed' then
|
|
186
|
+
raise exception 'last_active_board_owner: owner transitions require read committed'
|
|
187
|
+
using errcode = '25001';
|
|
188
|
+
end if;
|
|
189
|
+
-- Require the board mutex to have been acquired BEFORE the row lock. This
|
|
190
|
+
-- covers raw DML and cascades without taking a waiting lock in reverse order.
|
|
191
|
+
if not exists (
|
|
192
|
+
select 1 from pg_catalog.pg_locks l
|
|
193
|
+
where l.locktype = 'advisory' and l.pid = pg_catalog.pg_backend_pid()
|
|
194
|
+
and l.database = (select oid from pg_catalog.pg_database where datname = current_database())
|
|
195
|
+
and l.classid = ((v_key >> 32) & 4294967295)::oid
|
|
196
|
+
and l.objid = (v_key & 4294967295)::oid and l.objsubid = 1
|
|
197
|
+
and l.mode = 'ExclusiveLock' and l.granted
|
|
198
|
+
) then
|
|
199
|
+
raise exception 'last_active_board_owner: acquire board authority lock before owner transition'
|
|
200
|
+
using errcode = '55000';
|
|
201
|
+
end if;
|
|
202
|
+
-- Re-entrant: it CANNOT wait, since this backend already holds the exclusive
|
|
203
|
+
-- key. Promote a session-level lock to transaction lifetime so releasing the
|
|
204
|
+
-- session lock early cannot let a second departure miss our uncommitted exit.
|
|
205
|
+
perform pg_catalog.pg_advisory_xact_lock(v_key);
|
|
206
|
+
-- A separate volatile statement has a fresh read-committed snapshot.
|
|
207
|
+
if not exists (
|
|
208
|
+
select 1 from agent_board.board_memberships bm
|
|
209
|
+
where bm.board_id = old.board_id and bm.id <> old.id
|
|
210
|
+
and bm.role = 'owner' and bm.membership_state in ('joined', 'subscribed')
|
|
211
|
+
) then
|
|
212
|
+
raise exception 'last_active_board_owner: cannot remove the last active owner'
|
|
213
|
+
using errcode = '23514';
|
|
214
|
+
end if;
|
|
215
|
+
if tg_op = 'DELETE' then return old; else return new; end if;
|
|
216
|
+
end;
|
|
217
|
+
$function$;
|
|
218
|
+
|
|
219
|
+
revoke all on function agent_board._board_memberships_last_owner_guard()
|
|
220
|
+
from public, anon, authenticated, service_role, agent_board_delegate;
|
|
221
|
+
comment on function agent_board._board_memberships_last_owner_guard() is
|
|
222
|
+
'Consiliency/message-board#675: table-level UPDATE/DELETE guard against removing the last joined/subscribed owner of an existing board, including raw DML and cascades. No service-role bypass. Owner transitions require read committed and serialize to commit on the board authority advisory key; a pre-held exclusive board advisory lock is required (55000 otherwise) and reacquired re-entrantly for transaction lifetime. All waiting locks precede row locks. Existing ownerless boards and unrelated writes are unaffected; recovery by restoring ownership remains service_role-only.';
|
|
223
|
+
|
|
224
|
+
create trigger trg_board_memberships_last_owner_guard
|
|
225
|
+
before update or delete on agent_board.board_memberships
|
|
226
|
+
for each row execute function agent_board._board_memberships_last_owner_guard();
|
|
227
|
+
comment on trigger trg_board_memberships_last_owner_guard on agent_board.board_memberships is
|
|
228
|
+
'Refuse only a transition that removes an existing active owner and leaves no other active owner; every membership writer is covered by construction. Refusal-only: NEW is never rewritten. A future hard agent delete must acquire every owned board advisory lock before agent or membership row locks.';
|
|
229
|
+
|
|
230
|
+
revoke all on function agent_board.update_board_membership(uuid,uuid,text,jsonb)
|
|
231
|
+
from public, anon, agent_board_delegate;
|
|
232
|
+
grant execute on function agent_board.update_board_membership(uuid,uuid,text,jsonb)
|
|
233
|
+
to authenticated, service_role;
|
|
234
|
+
|
|
235
|
+
comment on function agent_board.update_board_membership(uuid,uuid,text,jsonb) is
|
|
236
|
+
'Authorized membership state transitions with existing self, principal-withdrawal, rank, consent and idempotency rules. Consiliency/message-board#675: board advisory lock before target membership/FK locks; the table refuses any transition removing the last active owner.';
|
|
237
|
+
|
|
238
|
+
comment on function agent_board._lock_board_authority_ceiling(uuid) is
|
|
239
|
+
'Board-keyed transaction advisory lock serializing the ABSENCE of a board_authority_ceilings row, held to commit. WRITERS[serialized: set_board_authority_ceiling][UNSERIALIZED: (none)] READERS[serialized: add_delegate_to_board, grant_board_role, link_session_alias, mint_delegated_child, onboard_federated_fleet_agent, readmit_session_identity][UNSERIALIZED: _redeem_unbounded_invite_for_agent, create_invite_link] The SIDSCHEMA readers take the mutexes for every requested or existing descendant room before root, identity and membership locks. The two UNSERIALIZED readers still evaluate a missing ceiling as default-owner without serializing its absence; that known race (message-board#277) is not changed here. Both lists are machine-compared against the catalog; a new reader/writer or changed serialization state fails by name. Internal invoker helper, not an authorization gate; caller privileges determine table access. Consiliency/message-board#675: also the per-board departure mutex, acquired by update_board_membership before row locks and held through commit; all destructive owner transitions require this key, including direct writes and hard agent-deletion cascades.';
|