Skip to content

Commit e3ff36c

Browse files
committed
fix(core): harden room RBAC recovery
1 parent 6ec5a45 commit e3ff36c

12 files changed

Lines changed: 252 additions & 54 deletions

cli/internal/core/core.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -289,16 +289,16 @@ func (c *ChattoCore) Run(ctx context.Context) error {
289289
if err := c.applyConfigOwners(gctx); err != nil {
290290
return fmt.Errorf("apply config owners: %w", err)
291291
}
292-
// Adopt every existing room before committing the server defaults
293-
// marker. This rollout boundary lets later unmarked rooms recover
294-
// interrupted creation-time initialization without reapplying
295-
// defaults that operators cleared on pre-marker rooms.
296-
if err := c.EnsureDefaultChannelRoomPermissions(gctx); err != nil {
297-
return fmt.Errorf("ensure default channel room permissions: %w", err)
298-
}
292+
// Commit the server marker with a durable evt.room.> cutoff before
293+
// adopting rooms. Every replica then classifies rooms against the
294+
// same boundary, including rooms created concurrently by an older
295+
// serving replica during a rolling deployment.
299296
if err := c.EnsureDefaultRolePermissions(gctx); err != nil {
300297
return fmt.Errorf("ensure default role permissions: %w", err)
301298
}
299+
if err := c.EnsureDefaultChannelRoomPermissions(gctx); err != nil {
300+
return fmt.Errorf("ensure default channel room permissions: %w", err)
301+
}
302302
// Seed the default room group and ensure every existing
303303
// channel room belongs to a set (ADR-031). Idempotent —
304304
// runs on every boot. Has to happen AFTER projectors are

cli/internal/core/permission_ops.go

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77

8+
"hmans.de/chatto/internal/events"
89
corev1 "hmans.de/chatto/internal/pb/chatto/core/v1"
910
)
1011

@@ -283,39 +284,50 @@ func (c *ChattoCore) SeedDefaultChannelRoomPermissions(ctx context.Context, room
283284
ScopeRoom,
284285
roomID,
285286
roomRBACDefaultsVersion,
286-
true,
287+
rbacDefaultsFillMissing,
287288
defaultChannelRoomDecisions(roomID, roomName),
289+
0,
288290
)
289291
}
290292

291-
// EnsureDefaultChannelRoomPermissions adopts existing rooms without changing
292-
// their decisions before the server initialization marker exists. On later
293-
// boots, an unmarked room was created after that rollout boundary and receives
294-
// its defaults plus marker atomically.
293+
// EnsureDefaultChannelRoomPermissions adopts rooms at or before the durable
294+
// server cutoff without changing their decisions. An unmarked room created
295+
// after that cutoff receives any missing defaults plus its marker atomically.
295296
func (c *ChattoCore) EnsureDefaultChannelRoomPermissions(ctx context.Context) error {
297+
serverInitialized := c.RBAC.DefaultsVersion(ScopeServer, "") >= serverRBACDefaultsVersion
298+
roomStreamCutoff := c.RBAC.ServerDefaultsRoomStreamCutoff()
299+
if serverInitialized && roomStreamCutoff > 0 {
300+
if err := c.RoomDirectoryProjector.WaitFor(ctx, events.SubjectPosition(events.RoomSubjectFilter(), roomStreamCutoff)); err != nil {
301+
return fmt.Errorf("wait for channel rooms through defaults cutoff: %w", err)
302+
}
303+
}
296304
rooms, err := c.ListRooms(ctx, KindChannel)
297305
if err != nil {
298306
return fmt.Errorf("list channel rooms: %w", err)
299307
}
300-
serverMarkerSeq := c.RBAC.DefaultsInitializedSeq(ScopeServer, "")
301308
for _, room := range rooms {
309+
seedMode := rbacDefaultsAdoptOnly
310+
if c.shouldRecoverUnmarkedRoom(room.Id, serverInitialized, roomStreamCutoff) {
311+
seedMode = rbacDefaultsFillMissing
312+
}
302313
if err := c.ensureRBACDefaultsInitialized(
303314
ctx,
304315
ScopeRoom,
305316
room.Id,
306317
roomRBACDefaultsVersion,
307-
c.shouldSeedUnmarkedRoom(room.Id, serverMarkerSeq),
318+
seedMode,
308319
defaultChannelRoomDecisions(room.Id, room.Name),
320+
0,
309321
); err != nil {
310322
return fmt.Errorf("ensure room permissions for %s: %w", room.Id, err)
311323
}
312324
}
313325
return nil
314326
}
315327

316-
func (c *ChattoCore) shouldSeedUnmarkedRoom(roomID string, serverMarkerSeq uint64) bool {
328+
func (c *ChattoCore) shouldRecoverUnmarkedRoom(roomID string, serverInitialized bool, roomStreamCutoff uint64) bool {
317329
createdSeq, ok := c.RoomCatalog.CreatedSeq(roomID)
318-
return serverMarkerSeq > 0 && ok && createdSeq > serverMarkerSeq
330+
return serverInitialized && ok && createdSeq > roomStreamCutoff
319331
}
320332

321333
// ============================================================================
@@ -362,13 +374,18 @@ func (c *ChattoCore) InitDefaultPermissions(ctx context.Context) error {
362374
// Current defaults are included only when RBAC has no permission decisions at
363375
// any scope; existing installations are marked without changing their state.
364376
func (c *ChattoCore) EnsureDefaultRolePermissions(ctx context.Context) error {
377+
roomStreamCutoff, err := c.EventPublisher.LastSubjectSeq(ctx, events.RoomSubjectFilter())
378+
if err != nil {
379+
return fmt.Errorf("read room stream cutoff for RBAC defaults: %w", err)
380+
}
365381
return c.ensureRBACDefaultsInitialized(
366382
ctx,
367383
ScopeServer,
368384
"",
369385
serverRBACDefaultsVersion,
370-
true,
386+
rbacDefaultsSeedWhenEmpty,
371387
defaultRBACDecisions(),
388+
roomStreamCutoff,
372389
)
373390
}
374391

cli/internal/core/projection_admin.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ func (p *RBACProjection) adminProjectionEstimate() (int64, int64, []ProjectionAd
259259
}
260260
var defaultsBytes int64
261261
for key := range p.defaults {
262-
defaultsBytes += projectionMapEntryOverhead + int64(len(key.scope)+len(key.scopeID)) + 4 + 8
262+
defaultsBytes += projectionMapEntryOverhead + int64(len(key.scope)+len(key.scopeID)) + 4 + 8 + 8
263263
}
264264
retainedEventIDs := p.replayGuard.retainedEventIDs()
265265
retainedEventIDsBytes := estimateStringSetBytes(retainedEventIDs)

cli/internal/core/rbac_defaults.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ const (
1616
roomRBACDefaultsVersion uint32 = 1
1717
)
1818

19+
type rbacDefaultsSeedMode uint8
20+
21+
const (
22+
rbacDefaultsAdoptOnly rbacDefaultsSeedMode = iota
23+
rbacDefaultsSeedWhenEmpty
24+
rbacDefaultsFillMissing
25+
)
26+
1927
// ensureRBACDefaultsInitialized atomically writes any selected defaults and a
2028
// durable version marker under the deployment-wide RBAC OCC filter. The
2129
// decision check is repeated after every conflict so a concurrent operator or
@@ -26,8 +34,9 @@ func (c *ChattoCore) ensureRBACDefaultsInitialized(
2634
scope PermissionScope,
2735
scopeID string,
2836
version uint32,
29-
seedWhenEmpty bool,
37+
seedMode rbacDefaultsSeedMode,
3038
defaults []rbacSeedDecision,
39+
roomStreamCutoff uint64,
3140
) error {
3241
filter := events.RBACSubjectFilter()
3342

@@ -45,10 +54,29 @@ func (c *ChattoCore) ensureRBACDefaultsInitialized(
4554
}
4655

4756
entries := make([]events.BatchEntry, 0, len(defaults)+1)
48-
if currentVersion == 0 && seedWhenEmpty && !c.hasPermissionDecisionsForDefaults(scope, scopeID) {
49-
entries = append(entries, rbacSeedEntries(nil, nil, append([]rbacSeedDecision(nil), defaults...))...)
57+
if currentVersion == 0 {
58+
var selected []rbacSeedDecision
59+
switch seedMode {
60+
case rbacDefaultsSeedWhenEmpty:
61+
if !c.hasPermissionDecisionsForDefaults(scope, scopeID) {
62+
selected = append(selected, defaults...)
63+
}
64+
case rbacDefaultsFillMissing:
65+
for _, decision := range defaults {
66+
if c.RBAC.getTypedDecision(
67+
decision.scope,
68+
decision.scopeID,
69+
decision.subjectKind,
70+
decision.subject,
71+
decision.permission,
72+
) == DecisionNone {
73+
selected = append(selected, decision)
74+
}
75+
}
76+
}
77+
entries = append(entries, rbacSeedEntries(nil, nil, selected)...)
5078
}
51-
entries = append(entries, rbacDefaultsInitializedEntry(scope, scopeID, version))
79+
entries = append(entries, rbacDefaultsInitializedEntry(scope, scopeID, version, roomStreamCutoff))
5280
entries[0].HasOCC = true
5381
entries[0].ExpectedSeq = filterSeq
5482
entries[0].FilterSubject = filter
@@ -82,8 +110,8 @@ func (c *ChattoCore) hasPermissionDecisionsForDefaults(scope PermissionScope, sc
82110
return c.RBAC.HasPermissionDecisions(scope, scopeID)
83111
}
84112

85-
func rbacDefaultsInitializedEntry(scope PermissionScope, scopeID string, version uint32) events.BatchEntry {
86-
marker := &corev1.RbacDefaultsInitializedEvent{Version: version}
113+
func rbacDefaultsInitializedEntry(scope PermissionScope, scopeID string, version uint32, roomStreamCutoff uint64) events.BatchEntry {
114+
marker := &corev1.RbacDefaultsInitializedEvent{Version: version, RoomStreamCutoff: roomStreamCutoff}
87115
switch scope {
88116
case ScopeServer:
89117
marker.Scope = &corev1.RbacDefaultsInitializedEvent_Server{Server: &corev1.RbacDefaultsInitializedEvent_ServerScope{}}

cli/internal/core/rbac_defaults_test.go

Lines changed: 116 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ func TestEnsureDefaultRolePermissions_SeedsEmptyRBACAtomically(t *testing.T) {
3838
}
3939

4040
func TestRBACDefaultsInitializedEntry_UsesScopedAggregate(t *testing.T) {
41-
server := rbacDefaultsInitializedEntry(ScopeServer, "", serverRBACDefaultsVersion)
41+
server := rbacDefaultsInitializedEntry(ScopeServer, "", serverRBACDefaultsVersion, 17)
4242
if want := events.RBACServerAggregate().Subject(events.EventRBACDefaultsInitialized); server.Subject != want {
4343
t.Fatalf("server marker subject = %q, want %q", server.Subject, want)
4444
}
45-
room := rbacDefaultsInitializedEntry(ScopeRoom, "Rabc123", roomRBACDefaultsVersion)
45+
room := rbacDefaultsInitializedEntry(ScopeRoom, "Rabc123", roomRBACDefaultsVersion, 0)
4646
if want := events.RBACScopedAggregate("Rabc123").Subject(events.EventRBACDefaultsInitialized); room.Subject != want {
4747
t.Fatalf("room marker subject = %q, want %q", room.Subject, want)
4848
}
@@ -79,10 +79,10 @@ func TestRBACDefaultsVersionAdvance_DoesNotReapplyDefaults(t *testing.T) {
7979
core := newRBACDefaultsTestCore(t, harness)
8080
ctx := testContext(t)
8181

82-
if err := core.ensureRBACDefaultsInitialized(ctx, ScopeServer, "", 1, false, defaultRBACDecisions()); err != nil {
82+
if err := core.ensureRBACDefaultsInitialized(ctx, ScopeServer, "", 1, rbacDefaultsAdoptOnly, defaultRBACDecisions(), 0); err != nil {
8383
t.Fatalf("write version 1 marker: %v", err)
8484
}
85-
if err := core.ensureRBACDefaultsInitialized(ctx, ScopeServer, "", 2, true, defaultRBACDecisions()); err != nil {
85+
if err := core.ensureRBACDefaultsInitialized(ctx, ScopeServer, "", 2, rbacDefaultsSeedWhenEmpty, defaultRBACDecisions(), 0); err != nil {
8686
t.Fatalf("advance to version 2: %v", err)
8787
}
8888

@@ -121,6 +121,86 @@ func TestSeedDefaultChannelRoomPermissions_MarkerPreservesClear(t *testing.T) {
121121
}
122122
}
123123

124+
func TestSeedDefaultChannelRoomPermissions_FillsMissingDefaultsAlongsideExistingDecision(t *testing.T) {
125+
harness := newTestEventHarness(t)
126+
core := newRBACDefaultsTestCore(t, harness)
127+
ctx := testContext(t)
128+
roomID := "Rpartial"
129+
130+
if err := core.DenyRoomPermission(ctx, SystemActorID, roomID, RoleEveryone, PermMessageReact); err != nil {
131+
t.Fatalf("DenyRoomPermission unrelated override: %v", err)
132+
}
133+
if err := core.SeedDefaultChannelRoomPermissions(ctx, roomID, AnnouncementsRoomName); err != nil {
134+
t.Fatalf("SeedDefaultChannelRoomPermissions: %v", err)
135+
}
136+
137+
if got := core.RBAC.GetDecision(ScopeRoom, roomID, RoleEveryone, PermMessageReact); got != DecisionDeny {
138+
t.Fatalf("existing unrelated decision = %s, want %s", got, DecisionDeny)
139+
}
140+
if got := core.RBAC.GetDecision(ScopeRoom, roomID, RoleEveryone, PermMessagePost); got != DecisionDeny {
141+
t.Fatalf("missing announcements default = %s, want %s", got, DecisionDeny)
142+
}
143+
}
144+
145+
func TestSeedDefaultChannelRoomPermissions_PreservesExistingSameKeyDecision(t *testing.T) {
146+
harness := newTestEventHarness(t)
147+
core := newRBACDefaultsTestCore(t, harness)
148+
ctx := testContext(t)
149+
roomID := "Roverride"
150+
151+
if err := core.GrantRoomPermission(ctx, SystemActorID, roomID, RoleEveryone, PermMessagePost); err != nil {
152+
t.Fatalf("GrantRoomPermission override: %v", err)
153+
}
154+
if err := core.SeedDefaultChannelRoomPermissions(ctx, roomID, AnnouncementsRoomName); err != nil {
155+
t.Fatalf("SeedDefaultChannelRoomPermissions: %v", err)
156+
}
157+
158+
if got := core.RBAC.GetDecision(ScopeRoom, roomID, RoleEveryone, PermMessagePost); got != DecisionAllow {
159+
t.Fatalf("same-key decision = %s, want preserved %s", got, DecisionAllow)
160+
}
161+
}
162+
163+
func TestEnsureDefaultChannelRoomPermissions_RecoversPostCutoffRoomWithExistingDecision(t *testing.T) {
164+
core, _ := setupTestCore(t)
165+
ctx := testContext(t)
166+
roomID := "Rpostcutoff"
167+
roomEvent := newEvent(SystemActorID, &corev1.Event{Event: &corev1.Event_RoomCreated{
168+
RoomCreated: &corev1.RoomCreatedEvent{
169+
RoomId: roomID,
170+
Kind: corev1.RoomKind_ROOM_KIND_CHANNEL,
171+
Name: AnnouncementsRoomName,
172+
},
173+
}})
174+
filterSeq, err := core.EventPublisher.LastSubjectSeq(ctx, events.RoomSubjectFilter())
175+
if err != nil {
176+
t.Fatalf("read room tail: %v", err)
177+
}
178+
roomSubject := events.RoomAggregate(roomID).SubjectFor(roomEvent)
179+
roomSeq, err := core.EventPublisher.AppendAtFilter(ctx, roomSubject, roomEvent, events.RoomSubjectFilter(), filterSeq)
180+
if err != nil {
181+
t.Fatalf("append post-cutoff room: %v", err)
182+
}
183+
if err := core.RoomDirectoryProjector.WaitFor(ctx, events.SubjectPosition(roomSubject, roomSeq)); err != nil {
184+
t.Fatalf("wait for post-cutoff room: %v", err)
185+
}
186+
if err := core.DenyRoomPermission(ctx, SystemActorID, roomID, RoleEveryone, PermMessageReact); err != nil {
187+
t.Fatalf("write unrelated partial decision: %v", err)
188+
}
189+
190+
if err := core.EnsureDefaultChannelRoomPermissions(ctx); err != nil {
191+
t.Fatalf("EnsureDefaultChannelRoomPermissions: %v", err)
192+
}
193+
if got := core.RBAC.GetDecision(ScopeRoom, roomID, RoleEveryone, PermMessageReact); got != DecisionDeny {
194+
t.Fatalf("existing unrelated decision = %s, want %s", got, DecisionDeny)
195+
}
196+
if got := core.RBAC.GetDecision(ScopeRoom, roomID, RoleEveryone, PermMessagePost); got != DecisionDeny {
197+
t.Fatalf("recovered announcements default = %s, want %s", got, DecisionDeny)
198+
}
199+
if got := core.RBAC.DefaultsVersion(ScopeRoom, roomID); got != roomRBACDefaultsVersion {
200+
t.Fatalf("room defaults version = %d, want %d", got, roomRBACDefaultsVersion)
201+
}
202+
}
203+
124204
func TestExistingRoomAdoption_PreservesCompletelyClearedScope(t *testing.T) {
125205
harness := newTestEventHarness(t)
126206
core := newRBACDefaultsTestCore(t, harness)
@@ -138,8 +218,9 @@ func TestExistingRoomAdoption_PreservesCompletelyClearedScope(t *testing.T) {
138218
ScopeRoom,
139219
roomID,
140220
roomRBACDefaultsVersion,
141-
false,
221+
rbacDefaultsAdoptOnly,
142222
defaultChannelRoomDecisions(roomID, AnnouncementsRoomName),
223+
0,
143224
); err != nil {
144225
t.Fatalf("adopt existing room: %v", err)
145226
}
@@ -165,18 +246,40 @@ func TestRoomDefaultsRolloutBoundaryUsesCreationSequence(t *testing.T) {
165246
}
166247

167248
applyRoomCreated("Rbefore", 10)
168-
marker := rbacDefaultsInitializedEntry(ScopeServer, "", serverRBACDefaultsVersion)
169-
if err := core.RBAC.Apply(marker.Event, 20); err != nil {
249+
applyRoomCreated("Rbetween", 30)
250+
marker := rbacDefaultsInitializedEntry(ScopeServer, "", serverRBACDefaultsVersion, 20)
251+
if err := core.RBAC.Apply(marker.Event, 40); err != nil {
170252
t.Fatalf("apply server marker: %v", err)
171253
}
172-
applyRoomCreated("Rafter", 30)
173254

174-
markerSeq := core.RBAC.DefaultsInitializedSeq(ScopeServer, "")
175-
if core.shouldSeedUnmarkedRoom("Rbefore", markerSeq) {
176-
t.Fatal("room created before the rollout marker would receive defaults")
255+
cutoff := core.RBAC.ServerDefaultsRoomStreamCutoff()
256+
if core.shouldRecoverUnmarkedRoom("Rbefore", true, cutoff) {
257+
t.Fatal("room created before the room cutoff would receive defaults")
258+
}
259+
if !core.shouldRecoverUnmarkedRoom("Rbetween", true, cutoff) {
260+
t.Fatal("room created after the cutoff but before the server marker would not recover defaults")
261+
}
262+
}
263+
264+
func TestEnsureDefaultRolePermissions_PersistsRoomStreamCutoff(t *testing.T) {
265+
harness := newTestEventHarness(t)
266+
core := newRBACDefaultsTestCore(t, harness)
267+
ctx := testContext(t)
268+
roomID := "Rcutoff"
269+
roomEvent := newEvent(SystemActorID, &corev1.Event{Event: &corev1.Event_RoomCreated{
270+
RoomCreated: &corev1.RoomCreatedEvent{RoomId: roomID, Kind: corev1.RoomKind_ROOM_KIND_CHANNEL},
271+
}})
272+
roomSubject := events.RoomAggregate(roomID).SubjectFor(roomEvent)
273+
roomSeq, err := harness.publisher.AppendAtFilter(ctx, roomSubject, roomEvent, events.RoomSubjectFilter(), 0)
274+
if err != nil {
275+
t.Fatalf("append room creation: %v", err)
276+
}
277+
278+
if err := core.EnsureDefaultRolePermissions(ctx); err != nil {
279+
t.Fatalf("EnsureDefaultRolePermissions: %v", err)
177280
}
178-
if !core.shouldSeedUnmarkedRoom("Rafter", markerSeq) {
179-
t.Fatal("room created after the rollout marker would not receive defaults")
281+
if got := core.RBAC.ServerDefaultsRoomStreamCutoff(); got != roomSeq {
282+
t.Fatalf("room stream cutoff = %d, want %d", got, roomSeq)
180283
}
181284
}
182285

0 commit comments

Comments
 (0)