-
Notifications
You must be signed in to change notification settings - Fork 689
fix(controller): grant Matrix power levels to human members on room join #1210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -60,6 +60,13 @@ type Client interface { | |
| // it falls back to the homeserver-admin identity. | ||
| SetRoomState(ctx context.Context, roomID, eventType, stateKey string, content map[string]interface{}, userToken string) error | ||
|
|
||
| // GetRoomState reads the content of a single state event from a room | ||
| // using the homeserver-admin identity (the event's `content` object, | ||
| // not the full event envelope). A room that has never had the event | ||
| // set (e.g. legacy rooms with no m.room.power_levels) yields (nil, nil) | ||
| // rather than an error; any other failure is returned. | ||
| GetRoomState(ctx context.Context, roomID, eventType, stateKey string) (map[string]interface{}, error) | ||
|
|
||
| // JoinRoom makes the user identified by token join the given room. | ||
| JoinRoom(ctx context.Context, roomID, userToken string) error | ||
|
|
||
|
|
@@ -753,6 +760,38 @@ func (c *TuwunelClient) SetRoomState(ctx context.Context, roomID, eventType, sta | |
| return nil | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| func (c *TuwunelClient) GetRoomState(ctx context.Context, roomID, eventType, stateKey string) (map[string]interface{}, error) { | ||
| token, err := c.ensureAdminToken(ctx) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("get room state %s %s: %w", roomID, eventType, err) | ||
| } | ||
| encodedRoom := encodeRoomID(roomID) | ||
| // Always include the state-key segment (trailing slash when the key is | ||
| // empty) to match SetRoomState — some strict homeservers reject the | ||
| // segment-less form for empty-key events. | ||
| path := fmt.Sprintf("/_matrix/client/v3/rooms/%s/state/%s/%s", | ||
| encodedRoom, url.PathEscape(eventType), url.PathEscape(stateKey)) | ||
| statusCode, respBody, err := c.doJSON(ctx, http.MethodGet, path, token, nil, nil) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("get room state %s %s: %w", roomID, eventType, err) | ||
| } | ||
| if statusCode == http.StatusNotFound { | ||
| return nil, nil // state event never set on this room | ||
| } | ||
| if statusCode != http.StatusOK { | ||
| return nil, fmt.Errorf("get room state %s %s: HTTP %d: %s", | ||
| roomID, eventType, statusCode, truncate(respBody, 500)) | ||
| } | ||
| // The state endpoint returns the state CONTENT object directly | ||
| // (e.g. {"users":{...},"ban":50}), not an event envelope — decode | ||
| // the wire response as-is. | ||
| var content map[string]interface{} | ||
| if err := json.Unmarshal(respBody, &content); err != nil { | ||
| return nil, fmt.Errorf("get room state %s %s: decode: %w", roomID, eventType, err) | ||
| } | ||
| return content, nil | ||
| } | ||
|
|
||
| func (c *TuwunelClient) JoinRoom(ctx context.Context, roomID, userToken string) error { | ||
| encodedRoom := encodeRoomID(roomID) | ||
| statusCode, respBody, err := c.doJSON(ctx, http.MethodPost, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1074,6 +1074,45 @@ func (p *Provisioner) EnsureRoomNonMember(ctx context.Context, roomID, userID, r | |
| return p.matrix.KickFromRoom(ctx, roomID, userID, reason) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The shallow copy |
||
| } | ||
|
|
||
| // EnsureRoomPowerLevel reconciles userID's entry in the room's | ||
| // m.room.power_levels to EXACTLY `level` (raising or lowering it) via the | ||
| // admin token. The complete existing content is preserved — every other | ||
| // user and every non-user field (events, invite, notifications, | ||
| // users_default, state_default, ban, kick, redact, extension fields); only | ||
| // the target users entry is mutated. Idempotent: no write when the user | ||
| // already has exactly `level`. Rooms that never had power_levels set | ||
| // (legacy rooms) start from an empty users map. | ||
| func (p *Provisioner) EnsureRoomPowerLevel(ctx context.Context, roomID, userID string, level int) error { | ||
| cur, err := p.matrix.GetRoomState(ctx, roomID, "m.room.power_levels", "") | ||
| if err != nil { | ||
| return fmt.Errorf("read power levels %s: %w", roomID, err) | ||
| } | ||
| // Preserve the complete existing content and mutate only the target | ||
| // users entry — rebuilding the struct would drop fields we don't | ||
| // explicitly know about (events, invite, notifications, extensions). | ||
| content := map[string]interface{}{} | ||
| var users map[string]interface{} | ||
| if cur != nil { | ||
| for k, v := range cur { | ||
| content[k] = v | ||
| } | ||
| users, _ = cur["users"].(map[string]interface{}) | ||
| } | ||
| if users == nil { | ||
| users = map[string]interface{}{} | ||
| } | ||
| // Exact-target semantics: a demoted human (e.g. permissionLevel 1 → 2, | ||
| // 100 → 50) must actually be lowered, not kept at the old level. | ||
| if have, ok := users[userID]; ok { | ||
| if n, ok := have.(float64); ok && int(n) == level { | ||
| return nil // already at exactly the desired level — no write | ||
| } | ||
| } | ||
| users[userID] = float64(level) | ||
| content["users"] = users | ||
| return p.matrix.SetRoomState(ctx, roomID, "m.room.power_levels", "", content, "") | ||
| } | ||
|
|
||
| // ReconcileRoomMembership drives the membership of roomID to match `desired` | ||
| // (a list of full Matrix user IDs). Users present in `desired` but not in | ||
| // the room are invited; users in the room but not in `desired` are kicked. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The healing pass calls
EnsureRoomPowerLevel(one GET, conditional PUT) for every desired room on every reconcile cycle, even when nothing has changed. For a human in N rooms this adds N Matrix API GETs per 5-minute requeue. The PR documents this trade-off and the exact-match short-circuit avoids writes, but consider adding a status annotation (e.g. alastPowerLevelGrantedmap in status) or a generation check to skip the GET whenspec.permissionLevelhasn't changed since the last successful grant. This is a future optimization, not a blocker.