Skip to content

Commit f5dca2d

Browse files
rtb-12claude
andauthored
feat(context): subgroup visibility + parent-walk membership inheritance (#2256) (#2261)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 927b0ea commit f5dca2d

52 files changed

Lines changed: 2183 additions & 332 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/e2e-rust-apps.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,12 @@ jobs:
167167
- workflow: group-capabilities
168168
file: workflows/group-capabilities.yml
169169
app: e2e-kv-store
170+
- workflow: group-subgroup-visibility-inheritance
171+
file: workflows/group-subgroup-visibility-inheritance.yml
172+
app: e2e-kv-store
173+
- workflow: group-default-capabilities-propagation
174+
file: workflows/group-default-capabilities-propagation.yml
175+
app: e2e-kv-store
170176
- workflow: group-reparent
171177
file: workflows/group-reparent.yml
172178
app: e2e-kv-store

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ description = "Core Calimero infrastructure and tools"
99
# Update workspace metadata (see docs/RELEASE.md for versioning and release)
1010
[workspace.metadata.workspaces]
1111
# Shared version of all public crates; bump this when cutting a release.
12-
version = "0.10.1-rc.31"
12+
version = "0.10.1-rc.32"
1313
exclude = [
1414
"./apps/abi_conformance",
1515
"./apps/blobs",

apps/e2e-kv-store/workflows/group-capabilities.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ steps:
4747

4848
# --- SCENARIO 1: Set default capabilities before inviting members ---
4949
# Capability bitmask: bit 0 = CAN_CREATE_CONTEXT (1),
50-
# bit 1 = CAN_INVITE_MEMBERS (2), bit 2 = CAN_JOIN_OPEN_CONTEXTS (4)
50+
# bit 1 = CAN_INVITE_MEMBERS (2), bit 2 = CAN_JOIN_OPEN_SUBGROUPS (4)
5151
# Value 7 = all capabilities enabled
5252

5353
- name: Set default capabilities to all (7)
@@ -111,8 +111,8 @@ steps:
111111

112112
# --- SCENARIO 4: Set visibility + verify context sync works ---
113113

114-
- name: Set default visibility to open
115-
type: set_default_visibility
114+
- name: Set subgroup visibility to open
115+
type: set_subgroup_visibility
116116
node: e2e-node-1
117117
group_id: '{{namespace_id}}'
118118
visibility: open
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
name: E2E KV Store - Default Capabilities Propagation
2+
description: >
3+
Regression test for issue #2256 / PR #2261: when an admin overrides
4+
the namespace's `default_capabilities` *before* a new member joins,
5+
the new member must pick up the overridden value — not a hard-coded
6+
fallback the joiner side previously substituted in.
7+
8+
The pre-fix bug: `create_group` writes `default_capabilities =
9+
CAN_JOIN_OPEN_SUBGROUPS` to the creator's local store but never
10+
publishes the corresponding `DefaultCapabilitiesSet` governance op,
11+
so a joiner couldn't read the value out of the governance DAG. A
12+
joiner-side mirror substituted the same hard-coded constant — which
13+
silently overrode any admin change made via `set_default_capabilities`.
14+
15+
The fix: the namespace's current `default_capabilities` is carried
16+
in the join-bundle's `default_capabilities` field, so the joiner
17+
reflects whatever the responder currently believes — including any
18+
admin-issued override. This workflow asserts that contract.
19+
20+
Flow:
21+
1. node-1 installs the app and creates a namespace. By construction
22+
of `create_group`, the local default is CAN_JOIN_OPEN_SUBGROUPS (4).
23+
2. node-1 admin overrides the default to 0 (no capabilities) via
24+
`set_default_capabilities`.
25+
3. node-1 invites node-2.
26+
4. node-2 joins the namespace.
27+
5. Assert node-2's individual capability is 0 (the admin's override),
28+
NOT 4 (the create-time hard-coded constant). If the assertion
29+
fails with `json_equal({{node2_caps}}, 4)` truthy, the fix has
30+
regressed.
31+
6. As a downstream sanity-check that the inherited cap actually
32+
gates Open-subgroup access: node-1 creates an Open subgroup
33+
containing a context, and node-2 tries to join that context.
34+
With caps=0 (no CAN_JOIN_OPEN_SUBGROUPS), the parent-walk in
35+
`check_group_membership` should refuse — `join_context` must
36+
fail. (`join_context_expect_failure` is the merobox action for
37+
a deliberate-deny scenario; if the runner doesn't support it,
38+
this step can be commented out — the cap-value assertion above
39+
is sufficient regression coverage.)
40+
41+
force_pull_image: false
42+
near_devnet: false
43+
44+
nodes:
45+
count: 2
46+
image: ghcr.io/calimero-network/merod:edge
47+
prefix: e2e-node
48+
49+
steps:
50+
# --- Setup ---
51+
52+
- name: Install application on node-1
53+
type: install_application
54+
node: e2e-node-1
55+
path: res/e2e_kv_store.wasm
56+
dev: true
57+
outputs:
58+
app_id: applicationId
59+
60+
- name: Assert application installed
61+
type: assert
62+
statements:
63+
- "is_set({{app_id}})"
64+
65+
- name: Create namespace on node-1
66+
type: create_namespace
67+
node: e2e-node-1
68+
application_id: '{{app_id}}'
69+
outputs:
70+
namespace_id: namespaceId
71+
72+
- name: Assert namespace created
73+
type: assert
74+
statements:
75+
- "is_set({{namespace_id}})"
76+
77+
# --- Admin overrides the create-time default ---
78+
# `create_group` populated defaults locally with bit 2
79+
# (CAN_JOIN_OPEN_SUBGROUPS = 4). The admin now restricts to 0.
80+
# Without the bundle-field fix, node-2 would still pick up the
81+
# hard-coded constant (4) instead of this override (0).
82+
83+
- name: Admin overrides default capabilities to 0
84+
type: set_default_capabilities
85+
node: e2e-node-1
86+
group_id: '{{namespace_id}}'
87+
capabilities: 0
88+
89+
# --- node-2 joins after the override ---
90+
91+
- name: Create namespace invitation for node-2
92+
type: create_namespace_invitation
93+
node: e2e-node-1
94+
namespace_id: '{{namespace_id}}'
95+
outputs:
96+
namespace_invitation: invitation
97+
98+
- name: Node-2 joins namespace
99+
type: join_namespace
100+
node: e2e-node-2
101+
namespace_id: '{{namespace_id}}'
102+
invitation: '{{namespace_invitation}}'
103+
outputs:
104+
member_identity_node2: memberIdentity
105+
106+
# --- Core regression assertion ---
107+
# If the joiner-side fallback wins, node-2 has caps=4. If the bundle
108+
# field wins (correct behavior), node-2 has caps=0.
109+
110+
- name: Get node-2 capabilities as seen by node-1
111+
type: get_member_capabilities
112+
node: e2e-node-1
113+
group_id: '{{namespace_id}}'
114+
member_id: '{{member_identity_node2}}'
115+
outputs:
116+
node2_caps_view_node1: capabilities
117+
118+
- name: Assert node-2 picked up admin's override (caps=0)
119+
type: json_assert
120+
statements:
121+
- "json_equal({{node2_caps_view_node1}}, 0)"
122+
123+
# --- Verify the same on node-2's own view (consistency across nodes) ---
124+
125+
- name: Get node-2 capabilities as seen by node-2 itself
126+
type: get_member_capabilities
127+
node: e2e-node-2
128+
group_id: '{{namespace_id}}'
129+
member_id: '{{member_identity_node2}}'
130+
outputs:
131+
node2_caps_view_node2: capabilities
132+
133+
- name: Assert node-2 self-view is consistent (caps=0)
134+
type: json_assert
135+
statements:
136+
- "json_equal({{node2_caps_view_node2}}, 0)"
137+
138+
stop_all_nodes: false
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
name: E2E KV Store - Subgroup Visibility Inheritance
2+
description: >
3+
End-to-end coverage for issue #2256 (subgroup visibility refactor).
4+
5+
Verifies that a member of a parent namespace, who holds the default
6+
CAN_JOIN_OPEN_SUBGROUPS capability, is automatically inherited as a
7+
member of any `Open` subgroup beneath that namespace -- including its
8+
contexts -- without an explicit `add_group_members` call. This is the
9+
payoff of moving visibility from the (unused) context level to the
10+
subgroup level.
11+
12+
Flow:
13+
1. node-1 installs the kv-store app and creates a namespace.
14+
2. node-2 joins the namespace via invitation. As a non-admin member
15+
it picks up the default capabilities, which include
16+
CAN_JOIN_OPEN_SUBGROUPS.
17+
3. node-1 creates a subgroup inside the namespace and flips its
18+
visibility to `open`.
19+
4. node-1 creates a context inside the Open subgroup. node-2 is NOT
20+
added to the subgroup directly.
21+
5. node-2 joins the context. With the parent-walk in
22+
`check_group_membership`, this should succeed: node-2's
23+
namespace membership + capability bit + Open child subgroup ⇒
24+
inherited membership.
25+
6. node-1 writes, node-2 reads, and the value matches -- proving
26+
node-2 is a fully functional context member, not just a passive
27+
subscriber.
28+
29+
force_pull_image: false
30+
near_devnet: false
31+
32+
nodes:
33+
count: 2
34+
image: ghcr.io/calimero-network/merod:edge
35+
prefix: e2e-node
36+
37+
steps:
38+
# --- Setup ---
39+
40+
- name: Install application on node-1
41+
type: install_application
42+
node: e2e-node-1
43+
path: res/e2e_kv_store.wasm
44+
dev: true
45+
outputs:
46+
app_id: applicationId
47+
48+
- name: Assert application installed
49+
type: assert
50+
statements:
51+
- "is_set({{app_id}})"
52+
53+
- name: Create namespace on node-1
54+
type: create_namespace
55+
node: e2e-node-1
56+
application_id: '{{app_id}}'
57+
outputs:
58+
namespace_id: namespaceId
59+
60+
- name: Assert namespace created
61+
type: assert
62+
statements:
63+
- "is_set({{namespace_id}})"
64+
65+
# --- node-2 joins the namespace (gets default caps incl.
66+
# CAN_JOIN_OPEN_SUBGROUPS) ---
67+
68+
- name: Create namespace invitation for node-2
69+
type: create_namespace_invitation
70+
node: e2e-node-1
71+
namespace_id: '{{namespace_id}}'
72+
outputs:
73+
namespace_invitation: invitation
74+
75+
- name: Node-2 joins namespace
76+
type: join_namespace
77+
node: e2e-node-2
78+
namespace_id: '{{namespace_id}}'
79+
invitation: '{{namespace_invitation}}'
80+
outputs:
81+
member_identity_node2: memberIdentity
82+
83+
# --- Create an Open subgroup with a context inside it ---
84+
85+
- name: Create subgroup inside namespace on node-1
86+
type: create_group_in_namespace
87+
node: e2e-node-1
88+
namespace_id: '{{namespace_id}}'
89+
group_alias: open-subgroup
90+
outputs:
91+
subgroup_id: groupId
92+
93+
- name: Mark subgroup as Open (inherits parent members)
94+
type: set_subgroup_visibility
95+
node: e2e-node-1
96+
group_id: '{{subgroup_id}}'
97+
visibility: open
98+
99+
- name: Create context inside the Open subgroup
100+
type: create_context
101+
node: e2e-node-1
102+
application_id: '{{app_id}}'
103+
group_id: '{{subgroup_id}}'
104+
outputs:
105+
sub_ctx_id: contextId
106+
107+
- name: Assert subgroup context created
108+
type: assert
109+
statements:
110+
- "is_set({{sub_ctx_id}})"
111+
112+
# --- The payoff: node-2 joins the context WITHOUT being added to the
113+
# subgroup. The parent-walk in check_group_membership should let
114+
# them in via inheritance. ---
115+
116+
- name: Node-2 joins subgroup context via inheritance
117+
type: join_context
118+
node: e2e-node-2
119+
context_id: '{{sub_ctx_id}}'
120+
outputs:
121+
ctx_key_node2: memberPublicKey
122+
123+
- name: Assert node-2 holds a context identity
124+
type: assert
125+
statements:
126+
- "is_set({{ctx_key_node2}})"
127+
128+
# --- Confirm node-2 is a real member, not just a passive subscriber ---
129+
130+
- name: Node-1 writes to the inherited context
131+
type: call
132+
node: e2e-node-1
133+
context_id: '{{sub_ctx_id}}'
134+
method: set
135+
args:
136+
key: "inherited_key"
137+
value: "from_node1"
138+
139+
- name: Wait for context sync between nodes
140+
type: wait_for_sync
141+
context_id: '{{sub_ctx_id}}'
142+
nodes:
143+
- e2e-node-1
144+
- e2e-node-2
145+
timeout: 60
146+
trigger_sync: true
147+
148+
- name: Node-2 reads the value via the inherited membership
149+
type: call
150+
node: e2e-node-2
151+
context_id: '{{sub_ctx_id}}'
152+
method: get
153+
args:
154+
key: "inherited_key"
155+
outputs:
156+
inherited_read: result
157+
158+
- name: Assert inheritance grants real read access
159+
type: json_assert
160+
statements:
161+
- 'json_equal({{inherited_read}}, {"output": "from_node1"})'
162+
163+
stop_all_nodes: false

architecture/local-governance.html

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,18 +143,27 @@ <h3>Subgroups</h3>
143143
<div class="g2">
144144
<div>
145145
<h4>Membership Inheritance</h4>
146-
<p>Membership checks walk up the ancestor chain (max depth 16). A member of the parent group is automatically a member of all descendant groups. Direct membership in the child takes priority over inherited membership.</p>
146+
<p>Each subgroup carries a <span class="code">subgroup_visibility</span> flag &mdash; <strong>Open</strong> or <strong>Restricted</strong> (absent = Restricted, the safe default). The membership check (<span class="code">check_group_membership_path</span>) walks up the ancestor chain (max depth 16) only through unbroken <strong>Open</strong> chains, anchored at the deepest ancestor where the identity holds a direct membership row. Direct membership in the target subgroup short-circuits to <span class="code">Direct</span>; otherwise the walk returns <span class="code">Inherited{anchor, via_admin}</span>. A <strong>Restricted</strong> ancestor is a wall &mdash; the walk stops with <span class="code">None</span>.</p>
147+
<p style="margin-top:8px;">At the anchor, admins inherit <strong>unconditionally</strong>; non-admins need the <span class="code">CAN_JOIN_OPEN_SUBGROUPS</span> capability bit at that anchor.</p>
147148
</div>
148149
<div>
149150
<h4>Admin Authority</h4>
150151
<p>Parent admins inherit <strong>structural</strong> governance over descendant groups: add/remove members, detach contexts, delete subgroups, set target application.</p>
151152
</div>
152153
</div>
153154

155+
<h4 style="margin-top:16px;">Open vs Restricted Subgroups</h4>
156+
<p><strong>Restricted</strong> subgroups are sealed: only direct members can read their governance ops or context state. Their per-subgroup encryption key is delivered via <span class="code">KeyDelivery</span> to direct joiners only.</p>
157+
<p style="margin-top:8px;"><strong>Open</strong> subgroups align the cryptographic boundary with the access boundary: their governance ops <em>and</em> context state deltas are encrypted with the <strong>parent namespace's key</strong> rather than a per-subgroup key. Every namespace member already holds the namespace key, so no separate key-delivery path is needed for inheritance-eligible members. The trade-off is explicit: the read-confidentiality boundary for an Open subgroup is namespace-wide, while the join/write boundary remains capability-gated by <span class="code">CAN_JOIN_OPEN_SUBGROUPS</span>.</p>
158+
<p style="margin-top:8px;">Receivers don't need a wire flag for the encryption choice &mdash; the <span class="code">key_id</span> resolves uniquely to either the subgroup or the namespace keyring, with the namespace keyring tried as a fallback after the subgroup keyring miss.</p>
159+
160+
<h4 style="margin-top:16px;">Sync-Stream Authorization</h4>
161+
<p>The responder-side stream-auth gate (<span class="code">DagHeadsRequest</span>, <span class="code">DeltaRequest</span>, snapshot stream) accepts both direct context membership <em>and</em> inheritance-eligible parent membership. Without this, an inheritance joiner's snapshot probe would be silently closed, leaving them stuck on the all-zero initial root hash. The auth gate uses the same parent-walk that powers join-time authorization, so the contract is consistent end-to-end: <em>namespace member + <span class="code">CAN_JOIN_OPEN_SUBGROUPS</span> = read + write + sync, with no extra round-trips</em>.</p>
162+
154163
<h4 style="margin-top:16px;">Restricting Access via Subgroups</h4>
155-
<p>To restrict access to specific contexts, create a <strong>subgroup</strong> and register those contexts under it. Only members of that subgroup (direct or inherited from its parent) can join its contexts. This replaces the former per-context visibility/allowlist model with a simpler group-membership-based approach.</p>
164+
<p>To restrict access to specific contexts, create a <strong>subgroup</strong> and either leave its visibility unset (Restricted by default) or register those contexts under it. Only members of that subgroup (direct or inherited via the Open-chain walk above) can join its contexts.</p>
156165

157-
<p style="margin-top:12px;">Create a subgroup with <span class="code">--parent-group-id</span> on <span class="code">meroctl group create</span> or via the <span class="code">parentGroupId</span> field in the API. The <span class="code">SubgroupCreated</span> governance op is published on the parent group's gossip topic.</p>
166+
<p style="margin-top:12px;">Create a subgroup with <span class="code">--parent-group-id</span> on <span class="code">meroctl group create</span> or via the <span class="code">parentGroupId</span> field in the API. The <span class="code">SubgroupCreated</span> governance op is published on the parent group's gossip topic. Flip visibility with <span class="code">PUT /groups/:id/settings/subgroup-visibility</span> or <span class="code">meroctl group set-subgroup-visibility</span>.</p>
158167
<p style="margin-top:8px;">When joining a parent group with <span class="code">auto_join</span> (default: true), the node subscribes to gossip topics and contexts in all descendant subgroups. <span class="code">MemberRemoved</span> cascades to descendant subgroup contexts (skipping child groups where the member has direct membership).</p>
159168
</div>
160169

0 commit comments

Comments
 (0)